From d2e9b0a65101f739877346f9d3da75e6cd0b2650 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:45:08 -0700 Subject: [PATCH 01/11] Report anonymous browser_session_started and plugin_enabled events Browser Automation reports browser_session_started when a session opens, with its surface (headless or desktop) and whether the app (user) or the CLI agents use (agent) opened it. The server reports plugin_enabled when a disabled plugin is turned on, using plugin_installed's rule that only built-in and curated marketplace plugins carry an id, so install-on-request built-ins that are enabled later are counted. Both respect the usage-data opt-out; privacy copy, configuration docs, and the API audit entry list them. Plugin SDK 0.6.42. Co-Authored-By: Claude Opus 5.5 --- .../services/plugins/plugin-registration.ts | 36 ++++++++++----- .../src/services/plugins/plugin-service.ts | 11 ++++- apps/server/src/services/system/telemetry.ts | 8 ++++ .../services/plugins/plugin-service.test.ts | 25 +++++++++++ apps/web/src/routes/privacy.tsx | 15 ++++--- docs/api_to_audit.md | 2 +- docs/configuration.md | 10 ++++- packages/domain/src/plugin-sdk-version.ts | 2 +- packages/plugin-sdk/package.json | 2 +- packages/server-contract/src/api/system.ts | 11 +++++ plugins/browser-automation/package.json | 2 +- plugins/browser-automation/server.test.ts | 45 +++++++++++++++++++ plugins/browser-automation/server.ts | 16 +++++-- plugins/browser-automation/telemetry.ts | 18 ++++++++ 14 files changed, 176 insertions(+), 27 deletions(-) create mode 100644 plugins/browser-automation/telemetry.ts diff --git a/apps/server/src/services/plugins/plugin-registration.ts b/apps/server/src/services/plugins/plugin-registration.ts index 9e6b26db6dd..b51d1efd9b7 100644 --- a/apps/server/src/services/plugins/plugin-registration.ts +++ b/apps/server/src/services/plugins/plugin-registration.ts @@ -58,30 +58,44 @@ import { type PluginResolvedUpdateVersion, } from "./update-resolver.js"; -export function pluginInstalledTelemetryEvent( - pluginId: string, - provenance: PluginProvenance, - sourceIntent: PluginSourceIntent, -): Extract { +function publicPluginIdentity(pluginId: string, provenance: PluginProvenance) { const isPublic = provenance.kind === "builtin" || (provenance.kind === "catalog" && (provenance.marketplace === CURATED_PLUGIN_MARKETPLACE_NAME || provenance.marketplace === BUNDLED_MARKETPLACE_NAME)); + return { + plugin_id: isPublic ? pluginId : null, + provenance: provenance.kind, + marketplace: + isPublic && provenance.kind === "catalog" ? provenance.marketplace : null, + }; +} + +export function pluginInstalledTelemetryEvent( + pluginId: string, + provenance: PluginProvenance, + sourceIntent: PluginSourceIntent, +): Extract { return { name: "plugin_installed", properties: { - plugin_id: isPublic ? pluginId : null, - provenance: provenance.kind, - marketplace: - isPublic && provenance.kind === "catalog" - ? provenance.marketplace - : null, + ...publicPluginIdentity(pluginId, provenance), source_kind: sourceIntent.kind, }, }; } +export function pluginEnabledTelemetryEvent( + pluginId: string, + provenance: PluginProvenance, +): Extract { + return { + name: "plugin_enabled", + properties: publicPluginIdentity(pluginId, provenance), + }; +} + interface PluginRegistrationContext { deps: PluginServiceDeps; bundledPlugins: readonly BundledPluginRegistration[]; diff --git a/apps/server/src/services/plugins/plugin-service.ts b/apps/server/src/services/plugins/plugin-service.ts index d1685f1df54..5e3e5ce1c98 100644 --- a/apps/server/src/services/plugins/plugin-service.ts +++ b/apps/server/src/services/plugins/plugin-service.ts @@ -155,7 +155,10 @@ import { type PluginHookProvider, } from "./plugin-hook-registry.js"; import type { PluginEnvironmentProviderBridge } from "./plugin-environment-provider-registry.js"; -import { createPluginRegistration } from "./plugin-registration.js"; +import { + createPluginRegistration, + pluginEnabledTelemetryEvent, +} from "./plugin-registration.js"; import { createPluginRuntime, forgetMutableRoot, @@ -1729,7 +1732,13 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { .map(({ id, displayName }) => ({ id, displayName })), ); } + const before = getInstalledPlugin(deps.db, id); if (!setInstalledPluginEnabled(deps.db, id, enabled)) return undefined; + if (enabled && before && !before.enabled) { + deps.telemetry.capture( + pluginEnabledTelemetryEvent(id, provenanceForRow(before)), + ); + } if (enabled) { const row = getInstalledPlugin(deps.db, id); if (row) { diff --git a/apps/server/src/services/system/telemetry.ts b/apps/server/src/services/system/telemetry.ts index 8b5160b8eea..da2e6655bb3 100644 --- a/apps/server/src/services/system/telemetry.ts +++ b/apps/server/src/services/system/telemetry.ts @@ -54,6 +54,14 @@ export type TelemetryEvent = first_turn: boolean; }; } + | { + name: "plugin_enabled"; + properties: { + plugin_id: string | null; + provenance: "builtin" | "catalog" | "direct"; + marketplace: string | null; + }; + } | { name: "plugin_installed"; properties: { diff --git a/apps/server/test/services/plugins/plugin-service.test.ts b/apps/server/test/services/plugins/plugin-service.test.ts index 82d19257790..9baaf93c639 100644 --- a/apps/server/test/services/plugins/plugin-service.test.ts +++ b/apps/server/test/services/plugins/plugin-service.test.ts @@ -863,6 +863,31 @@ describe("plugin service", () => { await tracked.stop(); }); + it("reports plugin_enabled only when a disabled plugin is turned on", async () => { + const captured: TelemetryEvent[] = []; + const tracked = createTelemetryTrackedService(captured); + const rootDir = await writePlugin(workDir, { + name: "bb-plugin-enable-tracked", + serverSource: "export default function plugin() {}", + }); + const installed = await tracked.installPath(rootDir); + captured.length = 0; + await tracked.setEnabled(installed.id, true); + await tracked.setEnabled(installed.id, false); + await tracked.setEnabled(installed.id, true); + expect(captured).toEqual([ + { + name: "plugin_enabled", + properties: { + plugin_id: null, + provenance: "direct", + marketplace: null, + }, + }, + ]); + await tracked.stop(); + }); + it("does not report plugin_installed during boot-time reconcile", async () => { const captured: TelemetryEvent[] = []; const tracked = createTelemetryTrackedService(captured); diff --git a/apps/web/src/routes/privacy.tsx b/apps/web/src/routes/privacy.tsx index 9c8668ca94f..701f2e4039e 100644 --- a/apps/web/src/routes/privacy.tsx +++ b/apps/web/src/routes/privacy.tsx @@ -79,13 +79,16 @@ function PrivacyRoute() { script), when you open a split view (how many panes it has), when an agent turn finishes (whether it completed, failed, or was stopped, a coarse failure type such as sign-in or rate limit, which built-in - agent ran it, and whether it was your first), and when you connect a + agent ran it, and whether it was your first), when you connect a phone for notifications or add another machine (only the kind of - device and whether it is your first of that kind). Each event - carries a random install ID, the app version, and your operating - system and architecture. Events never include your prompts, code, - file names, project names, or account details. Turn this off in - Settings → General → Share anonymous usage data, or set{" "} + device and whether it is your first of that kind), when a browser + automation session starts (headless or desktop, and whether an agent + or the app started it), and when you turn on a plugin (its id only + for built-in and marketplace plugins). Each event carries a random + install ID, the app version, and your operating system and + architecture. Events never include your prompts, code, file names, + project names, or account details. Turn this off in Settings → + General → Share anonymous usage data, or set{" "} BB_TELEMETRY=false.

diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index 40f9d79659b..77ab5d8c0dc 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -4044,7 +4044,7 @@ Stabilization requires exercising reconnect/reload, concurrent deduplication, ro ## `PluginBbSdk.system.experimental_recordTelemetryEvent` -`system.experimental_recordTelemetryEvent(event)` posts one anonymous product event to `POST /api/v1/system/telemetry/events`, which forwards it to the server's usage telemetry under the install's anonymous id and drops it when usage data sharing is off. The request schema is a strict allow-list of setup-guide, notification-card, tip, automation-created, split-view, and device-paired events with fixed enum, boolean, or small-count properties; anything else is rejected with 400. Core callers, all through `recordTelemetryEvent` in `apps/app/src/components/onboarding/onboarding-telemetry.ts`: the setup guide (`OnboardingFlow`), the sidebar notification card (`SidebarNotificationsPrompt` and `apps/app/src/hooks/useNotificationPermission.ts`), and the split view (`useSplitViewTelemetry` in `apps/app/src/lib/split-layout/`). Bundled plugin callers: Tips from its app code, Automations from its server `create` path, and Push Notifications from its device registration. Core also captures `device_paired` for new machines directly through the server's telemetry service. Stabilize after deciding whether third-party plugins may emit product events at all (and, if so, how events are namespaced per plugin and rate limited), then rename project-wide and remove this entry. +`system.experimental_recordTelemetryEvent(event)` posts one anonymous product event to `POST /api/v1/system/telemetry/events`, which forwards it to the server's usage telemetry under the install's anonymous id and drops it when usage data sharing is off. The request schema is a strict allow-list of setup-guide, notification-card, tip, automation-created, split-view, device-paired, and browser-session events with fixed enum, boolean, or small-count properties; anything else is rejected with 400. Core callers, all through `recordTelemetryEvent` in `apps/app/src/components/onboarding/onboarding-telemetry.ts`: the setup guide (`OnboardingFlow`), the sidebar notification card (`SidebarNotificationsPrompt` and `apps/app/src/hooks/useNotificationPermission.ts`), and the split view (`useSplitViewTelemetry` in `apps/app/src/lib/split-layout/`). Bundled plugin callers: Tips from its app code, Automations from its server `create` path, Push Notifications from its device registration, and Browser Automation when a session opens. Core also captures `device_paired` for new machines directly through the server's telemetry service. Stabilize after deciding whether third-party plugins may emit product events at all (and, if so, how events are namespaced per plugin and rate limited), then rename project-wide and remove this entry. ## `PluginBbSdk.hosts.experimental_discoverRepos` diff --git a/docs/configuration.md b/docs/configuration.md index 6012cf39a73..ba4343fb207 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2098,10 +2098,16 @@ view (the pane count after it opens, 2-8; restoring a saved layout does not count), and `device_paired` from the bundled Push Notifications plugin when a new phone registers for notifications (`mobile_ios` or `mobile_android`) and from the server when a new machine enrolls with a join code (`machine`; the -server's own machine and re-enrollments do not count), each with `first_of_kind`. That route accepts +server's own machine and re-enrollments do not count), each with `first_of_kind`, and `browser_session_started` from the bundled +Browser Automation plugin when a session opens (`surface`: `headless` or +`desktop`; `initiated_by`: `agent` for the CLI agents use, `user` for the app). +That route accepts only those events and their fixed, non-identifying properties. -The server also sends `turn_finished` whenever a thread's turn ends, from the same lifecycle transition that marks it finished, failed, or stopped (including provisioning failures, host disconnects, and manual stops): `outcome` +The server sends `plugin_enabled` when a disabled plugin is turned on, with the +same `plugin_id`, `provenance`, and `marketplace` rules as `plugin_installed` +(the id only for built-in and curated marketplace plugins). It also sends +`turn_finished` whenever a thread's turn ends, from the same lifecycle transition that marks it finished, failed, or stopped (including provisioning failures, host disconnects, and manual stops): `outcome` (`completed`, `failed`, or `stopped`), `provider` (a built-in provider id, or `other` for any custom or third-party provider), `error_category` (`none`, `auth`, `billing`, `rate_limit`, `provider_unavailable`, `limit_reached`, diff --git a/packages/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts index 0a11ef76b31..1f45cc3a72b 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.6.41"; +export const PLUGIN_SDK_VERSION = "0.6.42"; export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]); diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index b73afa0f815..247800024de 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@get-bb/plugin-sdk", - "version": "0.6.41", + "version": "0.6.42", "homepage": "https://github.com/get-bb/bb#readme", "bugs": { "url": "https://github.com/get-bb/bb/issues" diff --git a/packages/server-contract/src/api/system.ts b/packages/server-contract/src/api/system.ts index 7cfb2b81a36..7768754aa35 100644 --- a/packages/server-contract/src/api/system.ts +++ b/packages/server-contract/src/api/system.ts @@ -344,6 +344,17 @@ export const recordTelemetryEventRequestSchema = z.discriminatedUnion("name", [ .strict(), }) .strict(), + z + .object({ + name: z.literal("browser_session_started"), + properties: z + .object({ + surface: z.enum(["headless", "desktop"]), + initiated_by: z.enum(["agent", "user"]), + }) + .strict(), + }) + .strict(), z .object({ name: z.literal("split_view_opened"), diff --git a/plugins/browser-automation/package.json b/plugins/browser-automation/package.json index adbb46554c3..c7f4b7fa390 100644 --- a/plugins/browser-automation/package.json +++ b/plugins/browser-automation/package.json @@ -6,7 +6,7 @@ "description": "Let BB control the in-app browser and browsers on connected machines, with inline previews for headless sessions.", "engines": { "bb": ">=0.41", - "bbPluginSdk": ">=0.4.102" + "bbPluginSdk": ">=0.6.42" }, "bb": { "name": "Browser Automation", diff --git a/plugins/browser-automation/server.test.ts b/plugins/browser-automation/server.test.ts index 632def1f409..0c329ea1b9c 100644 --- a/plugins/browser-automation/server.test.ts +++ b/plugins/browser-automation/server.test.ts @@ -31,6 +31,10 @@ async function setup() { host.harness.sdk.stub("threads.get", async () => makeThreadResponse({ id: "thread-test" }), ); + host.harness.sdk.stub( + "system.experimental_recordTelemetryEvent", + async () => ({ ok: true }), + ); host.harness.sdk.stub( "experimental_desktopBrowsers.listInstances", async () => ({ @@ -166,6 +170,47 @@ describe("server session ownership", () => { }, ); + it("reports each opened session's surface and whether the app or an agent opened it", async () => { + const h = await setup(); + h.harness.sdk.stub("hosts.list", async () => [ + makeHostResponse({ id: "local-host", name: "Lab workstation" }), + ]); + try { + await h.open(); + const cli = await h.harness.behavior.runCli( + [ + "open", + "--backend", + "local", + "--machine", + "local-host", + "--headless", + "--json", + ], + { threadId: "thread-test" }, + ); + expect(cli.exitCode, cli.stderr).toBe(0); + expect( + h.harness.sdk.callsTo("system.experimental_recordTelemetryEvent"), + ).toEqual([ + [ + { + name: "browser_session_started", + properties: { surface: "desktop", initiated_by: "user" }, + }, + ], + [ + { + name: "browser_session_started", + properties: { surface: "headless", initiated_by: "agent" }, + }, + ], + ]); + } finally { + await h.harness.lifecycle.dispose(); + } + }); + it("prefers an exact machine ID over a matching name", async () => { const h = await setup(); h.harness.sdk.stub("hosts.list", async () => [ diff --git a/plugins/browser-automation/server.ts b/plugins/browser-automation/server.ts index 1a3a3810b1b..c0ea7486903 100644 --- a/plugins/browser-automation/server.ts +++ b/plugins/browser-automation/server.ts @@ -21,6 +21,10 @@ import { type BrowserCliRequest, } from "./cli.js"; import { previewDirective } from "./preview-directive.js"; +import { + recordBrowserSessionStarted, + type BrowserSessionInitiator, +} from "./telemetry.js"; const desktopSchema = z .object({ @@ -187,6 +191,7 @@ export default async function browserAutomationPlugin(bb: BbPluginApi) { async function open( input: z.output, signal: AbortSignal, + initiatedBy: BrowserSessionInitiator, ): Promise { let threadLifecycle = threadLifecycles.get(input.threadId); if (!threadLifecycle) { @@ -330,6 +335,10 @@ export default async function browserAutomationPlugin(bb: BbPluginApi) { }), ); } + void recordBrowserSessionStarted(bb, { + surface: session.backend === "local" ? "headless" : "desktop", + initiated_by: initiatedBy, + }); return session; } catch (error) { await finish(record, "closed").catch(() => {}); @@ -400,9 +409,10 @@ export default async function browserAutomationPlugin(bb: BbPluginApi) { } function handlers( signal: AbortSignal, + initiatedBy: BrowserSessionInitiator, ): PluginRpcHandlers { return { - open: (input) => open(input, signal), + open: (input) => open(input, signal, initiatedBy), async list({ threadId }) { const records = await Promise.all( ( @@ -440,13 +450,13 @@ export default async function browserAutomationPlugin(bb: BbPluginApi) { finish(await owned(input.threadId, input.sessionId), "closed"), }; } - bb.rpc.register(rpcContract, handlers(lifecycle.signal)); + bb.rpc.register(rpcContract, handlers(lifecycle.signal, "user")); function dispatch( method: BrowserCliMethod, input: unknown, signal: AbortSignal, ) { - const h = handlers(signal); + const h = handlers(signal, "agent"); switch (method) { case "open": return h.open(rpcContract.open.input.parse(input)); diff --git a/plugins/browser-automation/telemetry.ts b/plugins/browser-automation/telemetry.ts new file mode 100644 index 00000000000..8ca95627ab7 --- /dev/null +++ b/plugins/browser-automation/telemetry.ts @@ -0,0 +1,18 @@ +import type { BbPluginApi } from "@get-bb/plugin-sdk"; + +export type BrowserSessionInitiator = "agent" | "user"; + +export async function recordBrowserSessionStarted( + bb: Pick, + properties: { + surface: "headless" | "desktop"; + initiated_by: BrowserSessionInitiator; + }, +): Promise { + try { + await bb.sdk.system.experimental_recordTelemetryEvent({ + name: "browser_session_started", + properties, + }); + } catch {} +} From fa1efd1a5abe5cbc2857f0505333f35f266bfdef Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:48:18 -0700 Subject: [PATCH 02/11] Base initiated_by on whether the CLI ran inside a thread The app never opens sessions, so the old user/agent split described a distinction the code could not make. The CLI now reports agent when it runs inside a thread, as agents' shells do, user when it runs outside any thread, and sdk for direct SDK or RPC calls. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/routes/privacy.tsx | 16 +++++++------- docs/configuration.md | 4 +++- packages/server-contract/src/api/system.ts | 2 +- plugins/browser-automation/server.test.ts | 25 ++++++++++++++++++++-- plugins/browser-automation/server.ts | 6 ++++-- plugins/browser-automation/telemetry.ts | 2 +- 6 files changed, 40 insertions(+), 15 deletions(-) diff --git a/apps/web/src/routes/privacy.tsx b/apps/web/src/routes/privacy.tsx index 701f2e4039e..72e19224af9 100644 --- a/apps/web/src/routes/privacy.tsx +++ b/apps/web/src/routes/privacy.tsx @@ -82,14 +82,14 @@ function PrivacyRoute() { agent ran it, and whether it was your first), when you connect a phone for notifications or add another machine (only the kind of device and whether it is your first of that kind), when a browser - automation session starts (headless or desktop, and whether an agent - or the app started it), and when you turn on a plugin (its id only - for built-in and marketplace plugins). Each event carries a random - install ID, the app version, and your operating system and - architecture. Events never include your prompts, code, file names, - project names, or account details. Turn this off in Settings → - General → Share anonymous usage data, or set{" "} - BB_TELEMETRY=false. + automation session starts (headless or desktop, and whether it was + opened from an agent's thread, outside a thread, or through the + SDK), and when you turn on a plugin (its id only for built-in and + marketplace plugins). Each event carries a random install ID, the + app version, and your operating system and architecture. Events + never include your prompts, code, file names, project names, or + account details. Turn this off in Settings → General → + Share anonymous usage data, or set BB_TELEMETRY=false.

The iOS and Android apps store the following on your device:

diff --git a/docs/configuration.md b/docs/configuration.md index ba4343fb207..aad21ff3fde 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2100,7 +2100,9 @@ new phone registers for notifications (`mobile_ios` or `mobile_android`) and from the server when a new machine enrolls with a join code (`machine`; the server's own machine and re-enrollments do not count), each with `first_of_kind`, and `browser_session_started` from the bundled Browser Automation plugin when a session opens (`surface`: `headless` or -`desktop`; `initiated_by`: `agent` for the CLI agents use, `user` for the app). +`desktop`; `initiated_by`: `agent` when the CLI runs inside a thread, as agents' +shells do, `user` when it runs outside any thread, and `sdk` for a direct SDK or +RPC call). That route accepts only those events and their fixed, non-identifying properties. diff --git a/packages/server-contract/src/api/system.ts b/packages/server-contract/src/api/system.ts index 7768754aa35..0fa5a7f8dc3 100644 --- a/packages/server-contract/src/api/system.ts +++ b/packages/server-contract/src/api/system.ts @@ -350,7 +350,7 @@ export const recordTelemetryEventRequestSchema = z.discriminatedUnion("name", [ properties: z .object({ surface: z.enum(["headless", "desktop"]), - initiated_by: z.enum(["agent", "user"]), + initiated_by: z.enum(["agent", "user", "sdk"]), }) .strict(), }) diff --git a/plugins/browser-automation/server.test.ts b/plugins/browser-automation/server.test.ts index 0c329ea1b9c..6957b2c4153 100644 --- a/plugins/browser-automation/server.test.ts +++ b/plugins/browser-automation/server.test.ts @@ -170,7 +170,7 @@ describe("server session ownership", () => { }, ); - it("reports each opened session's surface and whether the app or an agent opened it", async () => { + it("reports each opened session's surface and whether an agent, a user, or the SDK opened it", async () => { const h = await setup(); h.harness.sdk.stub("hosts.list", async () => [ makeHostResponse({ id: "local-host", name: "Lab workstation" }), @@ -190,13 +190,28 @@ describe("server session ownership", () => { { threadId: "thread-test" }, ); expect(cli.exitCode, cli.stderr).toBe(0); + const terminal = await h.harness.behavior.runCli( + [ + "open", + "--backend", + "local", + "--machine", + "local-host", + "--headless", + "--thread", + "thread-test", + "--json", + ], + {}, + ); + expect(terminal.exitCode, terminal.stderr).toBe(0); expect( h.harness.sdk.callsTo("system.experimental_recordTelemetryEvent"), ).toEqual([ [ { name: "browser_session_started", - properties: { surface: "desktop", initiated_by: "user" }, + properties: { surface: "desktop", initiated_by: "sdk" }, }, ], [ @@ -205,6 +220,12 @@ describe("server session ownership", () => { properties: { surface: "headless", initiated_by: "agent" }, }, ], + [ + { + name: "browser_session_started", + properties: { surface: "headless", initiated_by: "user" }, + }, + ], ]); } finally { await h.harness.lifecycle.dispose(); diff --git a/plugins/browser-automation/server.ts b/plugins/browser-automation/server.ts index c0ea7486903..6bd1e82f964 100644 --- a/plugins/browser-automation/server.ts +++ b/plugins/browser-automation/server.ts @@ -450,13 +450,14 @@ export default async function browserAutomationPlugin(bb: BbPluginApi) { finish(await owned(input.threadId, input.sessionId), "closed"), }; } - bb.rpc.register(rpcContract, handlers(lifecycle.signal, "user")); + bb.rpc.register(rpcContract, handlers(lifecycle.signal, "sdk")); function dispatch( method: BrowserCliMethod, input: unknown, signal: AbortSignal, + initiatedBy: BrowserSessionInitiator, ) { - const h = handlers(signal, "agent"); + const h = handlers(signal, initiatedBy); switch (method) { case "open": return h.open(rpcContract.open.input.parse(input)); @@ -537,6 +538,7 @@ export default async function browserAutomationPlugin(bb: BbPluginApi) { context.signal ?? new AbortController().signal, lifecycle.signal, ]), + context.threadId === undefined ? "user" : "agent", ); const output = rpcContract.run.output.safeParse(result); const previewed = rpcContract.preview.output.safeParse(result); diff --git a/plugins/browser-automation/telemetry.ts b/plugins/browser-automation/telemetry.ts index 8ca95627ab7..016b932d7a4 100644 --- a/plugins/browser-automation/telemetry.ts +++ b/plugins/browser-automation/telemetry.ts @@ -1,6 +1,6 @@ import type { BbPluginApi } from "@get-bb/plugin-sdk"; -export type BrowserSessionInitiator = "agent" | "user"; +export type BrowserSessionInitiator = "agent" | "user" | "sdk"; export async function recordBrowserSessionStarted( bb: Pick, From a828c775d1266a2e364a56299580457baddbe355 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:55:05 -0700 Subject: [PATCH 03/11] Count only plugin_installed in the install telemetry test Re-enabling the plugin in that test now also reports plugin_enabled. Co-Authored-By: Claude Opus 5.5 --- apps/server/test/services/plugins/plugin-service.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/test/services/plugins/plugin-service.test.ts b/apps/server/test/services/plugins/plugin-service.test.ts index 9baaf93c639..c26e0b95268 100644 --- a/apps/server/test/services/plugins/plugin-service.test.ts +++ b/apps/server/test/services/plugins/plugin-service.test.ts @@ -859,7 +859,9 @@ describe("plugin service", () => { "disabled", ); expect((await tracked.setEnabled("tracked", true))?.status).toBe("running"); - expect(captured).toHaveLength(1); + expect( + captured.filter((event) => event.name === "plugin_installed"), + ).toHaveLength(1); await tracked.stop(); }); From 8e8c83a1a3f34736be8c2c243bdf327b8e4c7350 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:16:49 -0700 Subject: [PATCH 04/11] Retrigger CI Co-Authored-By: Claude Opus 5.5 From 8b0ae6692add55fddc0229314c09a76bab50c86e Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:34:06 -0700 Subject: [PATCH 05/11] Never let plugin_enabled telemetry fail turning a plugin on Building the event can throw for a corrupt catalog row, after the enable is already committed. The capture is now guarded and logged at debug, so the plugin still loads and the change is still announced. Co-Authored-By: Claude Opus 5.5 --- .../src/services/plugins/plugin-service.ts | 13 +++++-- .../services/plugins/plugin-service.test.ts | 37 ++++++++++++++++++- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/apps/server/src/services/plugins/plugin-service.ts b/apps/server/src/services/plugins/plugin-service.ts index 5e3e5ce1c98..f19a4fbfb1d 100644 --- a/apps/server/src/services/plugins/plugin-service.ts +++ b/apps/server/src/services/plugins/plugin-service.ts @@ -1735,9 +1735,16 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { const before = getInstalledPlugin(deps.db, id); if (!setInstalledPluginEnabled(deps.db, id, enabled)) return undefined; if (enabled && before && !before.enabled) { - deps.telemetry.capture( - pluginEnabledTelemetryEvent(id, provenanceForRow(before)), - ); + try { + deps.telemetry.capture( + pluginEnabledTelemetryEvent(id, provenanceForRow(before)), + ); + } catch (error) { + deps.logger.debug( + { err: error, pluginId: id }, + "Plugin enable telemetry failed", + ); + } } if (enabled) { const row = getInstalledPlugin(deps.db, id); diff --git a/apps/server/test/services/plugins/plugin-service.test.ts b/apps/server/test/services/plugins/plugin-service.test.ts index c26e0b95268..a620bfbe098 100644 --- a/apps/server/test/services/plugins/plugin-service.test.ts +++ b/apps/server/test/services/plugins/plugin-service.test.ts @@ -890,7 +890,42 @@ describe("plugin service", () => { await tracked.stop(); }); - it("does not report plugin_installed during boot-time reconcile", async () => { + it("still turns a plugin on when its enable telemetry throws", async () => { + const service = createPluginService({ + aiServices: createAiServiceRegistry(), + db, + hub: { + getDaemonSessionIdForHost: () => null, + notifyPluginSignal: () => 0, + notifySystem: () => {}, + }, + logger, + telemetry: { + ...createNoopTelemetryService(), + capture: (event) => { + if (event.name === "plugin_enabled") { + throw new Error("telemetry unavailable"); + } + }, + }, + dataDir: join(workDir, "data"), + appVersion: "0.9.0", + bundledPlugins: [], + loadTimeoutMs: 2000, + }); + const rootDir = await writePlugin(workDir, { + name: "bb-plugin-enable-guarded", + serverSource: "export default function plugin() {}", + }); + const installed = await service.installPath(rootDir); + await service.setEnabled(installed.id, false); + await expect(service.setEnabled(installed.id, true)).resolves.toMatchObject({ + status: "running", + }); + await service.stop(); + }); + + const captured: TelemetryEvent[] = []; const tracked = createTelemetryTrackedService(captured); const rootDir = await writePlugin(workDir, { From edaf4dc44c601c871360b44a324114599c4d9c3e Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:40:12 -0700 Subject: [PATCH 06/11] Restore the boot-time reconcile test header dropped by the previous edit Co-Authored-By: Claude Opus 5.5 --- .../test/services/plugins/plugin-service.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/server/test/services/plugins/plugin-service.test.ts b/apps/server/test/services/plugins/plugin-service.test.ts index a620bfbe098..33dad147eab 100644 --- a/apps/server/test/services/plugins/plugin-service.test.ts +++ b/apps/server/test/services/plugins/plugin-service.test.ts @@ -919,13 +919,15 @@ describe("plugin service", () => { }); const installed = await service.installPath(rootDir); await service.setEnabled(installed.id, false); - await expect(service.setEnabled(installed.id, true)).resolves.toMatchObject({ - status: "running", - }); + await expect(service.setEnabled(installed.id, true)).resolves.toMatchObject( + { + status: "running", + }, + ); await service.stop(); }); - + it("does not report plugin_installed during boot-time reconcile", async () => { const captured: TelemetryEvent[] = []; const tracked = createTelemetryTrackedService(captured); const rootDir = await writePlugin(workDir, { From f685412b8ba1ad318b8d3721451ea1fff161882d Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:10:16 -0700 Subject: [PATCH 07/11] Capture anonymous plugin usage at shared action boundaries --- apps/app/src/App.tsx | 2 + .../components/plugin/PluginPanelActions.tsx | 2 + apps/app/src/lib/plugin-usage.ts | 25 +++ apps/server/src/routes/plugins.ts | 9 + apps/server/src/server.ts | 10 +- .../server/src/services/plugins/plugin-api.ts | 18 +- .../plugins/plugin-feature-telemetry.ts | 105 +++++++++ .../services/plugins/plugin-registration.ts | 36 +--- .../src/services/plugins/plugin-runtime.ts | 3 + .../src/services/plugins/plugin-service.ts | 48 +++-- .../src/services/system/feature-telemetry.ts | 12 +- .../plugins/plugin-replacement.test.ts | 20 +- .../services/plugins/plugin-service.test.ts | 200 +++++++++++++----- docs/api_to_audit.md | 6 + docs/configuration.md | 2 + packages/plugin-sdk/src/backend-contract.ts | 8 +- .../plugin-sdk/src/internal/host-policy.ts | 15 +- packages/plugin-sdk/src/rpc-contract.ts | 1 + .../src/testing/fake-plugin-host.ts | 1 + packages/sdk/src/areas/plugins.ts | 8 + plugins/account-pool/src/rpc.ts | 15 ++ plugins/agent-annotations/server.ts | 2 + plugins/automations/package.json | 2 +- plugins/automations/src/rpc.ts | 6 + plugins/automations/src/run.ts | 16 +- plugins/bb-account/src/contract.ts | 4 + plugins/bb-ai/src/server.ts | 1 + plugins/browser-automation/contracts.ts | 15 +- plugins/browser-automation/package.json | 2 +- plugins/browser-automation/server.test.ts | 66 ------ plugins/browser-automation/server.ts | 18 +- plugins/browser-automation/telemetry.ts | 18 -- plugins/concurrency-limit/server.ts | 1 + plugins/connect/src/rpc.ts | 22 +- plugins/docs/server.ts | 16 ++ plugins/environment-modal-sandbox/account.ts | 32 ++- plugins/github/server.ts | 14 +- plugins/keep-awake/server.ts | 1 + plugins/memory/server.ts | 2 + plugins/monaco-editor/server.ts | 1 + plugins/plugin-api-docs/src/surfaces.ts | 5 + plugins/prompt-library/contract.ts | 3 + .../provider-acp-next/src/registry-service.ts | 12 +- plugins/push-notifications/contract.ts | 4 + plugins/side-chat/server.ts | 1 + plugins/storage-retention/src/contract.ts | 32 ++- plugins/tasks/delegate/contract.ts | 3 + plugins/tasks/shared/contract.ts | 21 ++ plugins/theme-preview/server.ts | 1 + plugins/thread-list/server.ts | 24 ++- plugins/tips/contract.ts | 16 +- plugins/workflows/src/ui-contract.ts | 1 + 52 files changed, 640 insertions(+), 268 deletions(-) create mode 100644 apps/app/src/lib/plugin-usage.ts create mode 100644 apps/server/src/services/plugins/plugin-feature-telemetry.ts delete mode 100644 plugins/browser-automation/telemetry.ts diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 5a9d0b3ddc4..0bde326c4f2 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -1,3 +1,4 @@ +import { usePluginPageUsage } from "./lib/plugin-usage"; import { PluginUpdateJobsHost } from "./components/plugin/PluginUpdateJobsHost"; import { LazyThreadDetailView } from "./views/thread-detail/LazyThreadDetailView"; import { LazyRootComposeView } from "./views/LazyRootComposeView"; @@ -285,6 +286,7 @@ export function HashNavigationScroll() { } export function AppRoutes() { + usePluginPageUsage(); const { isThreadView } = useRouteState(); const isRootComposeView = useLocation().pathname === APP_ROOT_ROUTE_PATH; useEffect(() => { diff --git a/apps/app/src/components/plugin/PluginPanelActions.tsx b/apps/app/src/components/plugin/PluginPanelActions.tsx index 7ba8e0e94bf..2afa5cbe6cb 100644 --- a/apps/app/src/components/plugin/PluginPanelActions.tsx +++ b/apps/app/src/components/plugin/PluginPanelActions.tsx @@ -1,3 +1,4 @@ +import { recordPluginPanelOpen } from "@/lib/plugin-usage"; import { useMemo, type ReactNode } from "react"; import type { PluginPanelActionOpenOptions } from "@get-bb/plugin-sdk"; import { EmptyStatePanel } from "@bb/shared-ui/empty-state"; @@ -70,6 +71,7 @@ export function createPanelActionOpenPanel({ title: options?.title ?? action.title, paramsJson, }); + recordPluginPanelOpen(action.pluginId); return true; }; } diff --git a/apps/app/src/lib/plugin-usage.ts b/apps/app/src/lib/plugin-usage.ts new file mode 100644 index 00000000000..8c434241123 --- /dev/null +++ b/apps/app/src/lib/plugin-usage.ts @@ -0,0 +1,25 @@ +import { useEffect, useRef } from "react"; +import { useLocation } from "react-router-dom"; +import { sdk } from "@/lib/sdk"; +import { getPluginPanelRoutePluginId } from "@/lib/route-paths"; + +export function recordPluginPanelOpen(pluginId: string): void { + try { + void sdk.plugins + .experimental_recordPanelOpen({ pluginId }) + .catch(() => undefined); + } catch {} +} + +export function usePluginPageUsage(): void { + const { pathname } = useLocation(); + const previous = useRef(pathname); + useEffect(() => { + if (previous.current === pathname) return; + const previousPlugin = getPluginPanelRoutePluginId(previous.current); + const pluginId = getPluginPanelRoutePluginId(pathname); + previous.current = pathname; + if (pluginId !== null && pluginId !== previousPlugin) + recordPluginPanelOpen(pluginId); + }, [pathname]); +} diff --git a/apps/server/src/routes/plugins.ts b/apps/server/src/routes/plugins.ts index 6b445aafb50..c27c01da3c3 100644 --- a/apps/server/src/routes/plugins.ts +++ b/apps/server/src/routes/plugins.ts @@ -450,6 +450,15 @@ export function registerPluginRoutes( }); }); + app.post("/plugins/:id/usage", (context) => { + const problem = localAuthProblem(context, deps); + if (problem) + return context.json({ ok: false, error: problem.error }, problem.status); + if (!plugins.recordPanelOpen(context.req.param("id"))) + return context.json({ ok: false }, 404); + return context.json({ ok: true }); + }); + app.get("/plugins/rpc", (context) => { const query = pluginRpcDiscoveryQuerySchema.safeParse(context.req.query()); if (!query.success) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index edaaf7c4d1b..aa8cd54dffe 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -879,7 +879,15 @@ export function createApp( const publicApi = new Hono(); publicApi.use( "*", - coreFeatureTelemetry(() => deps.telemetry), + coreFeatureTelemetry( + () => deps.telemetry, + (context) => { + const caller = pluginService.resolveRpcCaller( + context.req.header("x-bb-plugin-caller"), + ); + return caller.ok && caller.caller.kind === "plugin"; + }, + ), ); publicApi.use("*", async (context, next) => { if (PLUGIN_WIRE_HTTP_PATH.test(context.req.path)) { diff --git a/apps/server/src/services/plugins/plugin-api.ts b/apps/server/src/services/plugins/plugin-api.ts index a0841481fbe..efeed0dca94 100644 --- a/apps/server/src/services/plugins/plugin-api.ts +++ b/apps/server/src/services/plugins/plugin-api.ts @@ -1,3 +1,4 @@ +import { telemetryTriggerSchema } from "@bb/server-contract"; import { environmentCompositionSchema, validateServerAccessProviderDeclaration, @@ -191,6 +192,7 @@ export interface PluginWebSocketRouteRecord { } export interface PluginRpcHandler { + usage?: "action" | "query" | null; publication: ReturnType; inputSchema: StandardSchemaV1; outputSchema: StandardSchemaV1; @@ -343,17 +345,13 @@ function withPluginThreadAttribution< return { ...args, ...attribution }; } -function wrapSdkForPlugin( - sdk: BbSdk, - pluginId: string, - rpcCallerSdk: BbSdk, -): PluginBbSdk { +function wrapSdkForPlugin(pluginId: string, sdk: BbSdk): PluginBbSdk { return { ...sdk, plugins: { ...sdk.plugins, callRpc(args) { - return rpcCallerSdk.plugins.callRpc(args); + return sdk.plugins.callRpc(args); }, }, threads: { @@ -478,6 +476,9 @@ function createStagedRegistrations< const PLUGIN_HOST_CALL_MAX_TIMEOUT_MS = 30 * 60_000; export function createPluginApi(options: { + recordFeatureUse?: ( + trigger: import("@bb/server-contract").TelemetryTrigger, + ) => void; pluginId: string; logger: ServerLogger; db: DbConnection; @@ -1323,6 +1324,10 @@ export function createPluginApi(options: { }; const api: BbPluginApi = { + experimental_recordFeatureUse(value) { + const parsed = telemetryTriggerSchema.safeParse(value?.trigger); + if (parsed.success) options.recordFeatureUse?.(parsed.data); + }, pluginId, log, settings, @@ -1355,7 +1360,6 @@ export function createPluginApi(options: { ); } wrappedSdk ??= wrapSdkForPlugin( - sdk, pluginId, createPluginRpcCallerSdk({ baseUrl: loopbackBaseUrl, diff --git a/apps/server/src/services/plugins/plugin-feature-telemetry.ts b/apps/server/src/services/plugins/plugin-feature-telemetry.ts new file mode 100644 index 00000000000..7912af9ee93 --- /dev/null +++ b/apps/server/src/services/plugins/plugin-feature-telemetry.ts @@ -0,0 +1,105 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { getInstalledPlugin, getPluginMarketplace } from "@bb/db"; +import { + CURATED_PLUGIN_MARKETPLACE_NAME, + type CoreFeature, + type TelemetryTrigger, + type TelemetryPluginSource, +} from "@bb/server-contract"; +import { + CURATED_MARKETPLACE_V1_URL, + CURATED_MARKETPLACE_V2_URL, +} from "../plugin-catalog/marketplace-manifest.js"; +import { captureFeatureUsed } from "../system/feature-telemetry.js"; +import type { PluginServiceDeps } from "./plugin-service-internal.js"; + +const invocation = new AsyncLocalStorage(); +const capabilities: Readonly> = { + automations: "automation", + "browser-automation": "browser", + connect: "remote_access", + "push-notifications": "remote_access", + github: "git", + "monaco-editor": "file", + docs: "file", + "side-chat": "child_thread", + tasks: "thread", + workflows: "child_thread", + "concurrency-limit": "settings", + "keep-awake": "settings", + "thread-list": "settings", + "theme-preview": "settings", + "storage-retention": "settings", + "bb-account": "settings", + "bb-ai": "provider", + "account-pool": "provider", + "provider-usage": "provider", + "environment-git-worktree": "environment", + "environment-project-checkout": "environment", + "environment-personal-workspace": "environment", + "environment-modal-sandbox": "environment", +}; + +export function capturePluginUse( + deps: PluginServiceDeps, + pluginId: string, + interaction: "opened" | "invoked" | "ran", + trigger: TelemetryTrigger, +): void { + if (invocation.getStore()) return; + try { + const row = getInstalledPlugin(deps.db, pluginId); + if (!row || !row.enabled) return; + let source: TelemetryPluginSource = "unknown"; + if ( + row.sourceKind === "path" || + (row.sourceKind === "builtin" && deps.watchBuiltinPluginSources === true) + ) + source = "local"; + else if (row.sourceKind === "builtin" && row.provenance === "builtin") + source = "builtin"; + else if ( + row.provenance === "catalog" && + row.catalogMarketplaceName !== null + ) { + const marketplace = getPluginMarketplace( + deps.db, + row.catalogMarketplaceName, + ); + if ( + marketplace?.name === CURATED_PLUGIN_MARKETPLACE_NAME && + marketplace.sourceKind === "https" && + [CURATED_MARKETPLACE_V1_URL, CURATED_MARKETPLACE_V2_URL].includes( + marketplace.manifestUrl, + ) + ) + source = "public_catalog"; + else if (marketplace?.sourceKind === "git") + source = "private_marketplace"; + } + const feature = + row.provenance === "builtin" && row.sourceKind === "builtin" + ? (capabilities[pluginId] ?? + (pluginId.startsWith("provider-") ? "provider" : "plugin")) + : "plugin"; + captureFeatureUsed(deps.telemetry, { + feature, + feature_scope: "plugin", + plugin_source: source, + interaction, + trigger, + }); + } catch {} +} + +export function withPluginUse( + deps: PluginServiceDeps, + pluginId: string, + trigger: TelemetryTrigger, + run: () => T, + capture = true, +): T { + if (invocation.getStore()) return run(); + if (capture) capturePluginUse(deps, pluginId, "invoked", trigger); + return invocation.run(true, run); +} diff --git a/apps/server/src/services/plugins/plugin-registration.ts b/apps/server/src/services/plugins/plugin-registration.ts index b51d1efd9b7..9e6b26db6dd 100644 --- a/apps/server/src/services/plugins/plugin-registration.ts +++ b/apps/server/src/services/plugins/plugin-registration.ts @@ -58,44 +58,30 @@ import { type PluginResolvedUpdateVersion, } from "./update-resolver.js"; -function publicPluginIdentity(pluginId: string, provenance: PluginProvenance) { - const isPublic = - provenance.kind === "builtin" || - (provenance.kind === "catalog" && - (provenance.marketplace === CURATED_PLUGIN_MARKETPLACE_NAME || - provenance.marketplace === BUNDLED_MARKETPLACE_NAME)); - return { - plugin_id: isPublic ? pluginId : null, - provenance: provenance.kind, - marketplace: - isPublic && provenance.kind === "catalog" ? provenance.marketplace : null, - }; -} - export function pluginInstalledTelemetryEvent( pluginId: string, provenance: PluginProvenance, sourceIntent: PluginSourceIntent, ): Extract { + const isPublic = + provenance.kind === "builtin" || + (provenance.kind === "catalog" && + (provenance.marketplace === CURATED_PLUGIN_MARKETPLACE_NAME || + provenance.marketplace === BUNDLED_MARKETPLACE_NAME)); return { name: "plugin_installed", properties: { - ...publicPluginIdentity(pluginId, provenance), + plugin_id: isPublic ? pluginId : null, + provenance: provenance.kind, + marketplace: + isPublic && provenance.kind === "catalog" + ? provenance.marketplace + : null, source_kind: sourceIntent.kind, }, }; } -export function pluginEnabledTelemetryEvent( - pluginId: string, - provenance: PluginProvenance, -): Extract { - return { - name: "plugin_enabled", - properties: publicPluginIdentity(pluginId, provenance), - }; -} - interface PluginRegistrationContext { deps: PluginServiceDeps; bundledPlugins: readonly BundledPluginRegistration[]; diff --git a/apps/server/src/services/plugins/plugin-runtime.ts b/apps/server/src/services/plugins/plugin-runtime.ts index 4b5cc668f08..48f75914247 100644 --- a/apps/server/src/services/plugins/plugin-runtime.ts +++ b/apps/server/src/services/plugins/plugin-runtime.ts @@ -1,3 +1,4 @@ +import { capturePluginUse } from "./plugin-feature-telemetry.js"; import type { MachineEnrollmentService } from "../machines/machine-services.js"; import { AsyncLocalStorage } from "node:async_hooks"; import { @@ -1621,6 +1622,8 @@ export function createPluginRuntime(context: PluginRuntimeContext) { current: {}, }; const handle = createPluginApi({ + recordFeatureUse: (trigger) => + capturePluginUse(deps, row.id, "ran", trigger), pluginId: row.id, logger: deps.logger, db: deps.db, diff --git a/apps/server/src/services/plugins/plugin-service.ts b/apps/server/src/services/plugins/plugin-service.ts index 89409343562..798f920a87f 100644 --- a/apps/server/src/services/plugins/plugin-service.ts +++ b/apps/server/src/services/plugins/plugin-service.ts @@ -1,3 +1,5 @@ +import { capturePluginUse, withPluginUse } from "./plugin-feature-telemetry.js"; +import { currentTelemetryTrigger } from "../system/telemetry.js"; import type { PluginRpcDiscoveryQuery, PublishedPluginRpcMethod, @@ -160,10 +162,7 @@ import { type PluginHookProvider, } from "./plugin-hook-registry.js"; import type { PluginEnvironmentProviderBridge } from "./plugin-environment-provider-registry.js"; -import { - createPluginRegistration, - pluginEnabledTelemetryEvent, -} from "./plugin-registration.js"; +import { createPluginRegistration } from "./plugin-registration.js"; import { createPluginRuntime, forgetMutableRoot, @@ -415,6 +414,7 @@ export interface PluginService { options?: { rotate?: boolean }, ): Promise; listCliContributions(): PluginCliContribution[]; + recordPanelOpen(id: string): boolean; runCliCommand( id: string, argv: string[], @@ -1762,22 +1762,9 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { .map(({ id, displayName }) => ({ id, displayName })), ); } - const before = getInstalledPlugin(deps.db, pluginId); if (!setInstalledPluginEnabled(deps.db, pluginId, nextEnabled)) { return false; } - if (nextEnabled && before && !before.enabled) { - try { - deps.telemetry.capture( - pluginEnabledTelemetryEvent(pluginId, provenanceForRow(before)), - ); - } catch (error) { - deps.logger.debug( - { err: error, pluginId }, - "Plugin enable telemetry failed", - ); - } - } if (nextEnabled) { const row = getInstalledPlugin(deps.db, pluginId); if (row) { @@ -2165,9 +2152,16 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { "input", throwRpcBoundaryError, ); - const result = await handler.handler(parsedInput, { - experimental_caller: caller, - }); + const result = await withPluginUse( + deps, + id, + currentTelemetryTrigger(), + () => + handler.handler(parsedInput, { + experimental_caller: caller, + }), + handler.usage === "action" && caller.kind === "client", + ); const parsedOutput = await validateRpcValue( handler.outputSchema, result, @@ -2204,6 +2198,12 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { return cliContributions(); }, + recordPanelOpen(id) { + if (!loaded.has(id)) return false; + capturePluginUse(deps, id, "opened", currentTelemetryTrigger()); + return true; + }, + async runCliCommand(id, argv, ctx) { const fail = (stderr: string) => enforcePluginCliOutputLimit( @@ -2227,7 +2227,9 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { id, `cli ${registration.name}`, async () => { - const result = await registration.run(argv, ctx); + const result = await withPluginUse(deps, id, "unknown", () => + registration.run(argv, ctx), + ); if (typeof result?.exitCode !== "number") { throw new Error( "cli run() must return { exitCode: number, stdout?, stderr? }", @@ -2456,7 +2458,9 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { pluginId, `tool ${record.name}`, async () => { - const result = await record.execute(parsed.value, ctx); + const result = await withPluginUse(deps, pluginId, "agent", () => + record.execute(parsed.value, ctx), + ); return normalizeAgentToolResult(record.name, result); }, ); diff --git a/apps/server/src/services/system/feature-telemetry.ts b/apps/server/src/services/system/feature-telemetry.ts index cf7d6dc2411..3f3c0ab7db8 100644 --- a/apps/server/src/services/system/feature-telemetry.ts +++ b/apps/server/src/services/system/feature-telemetry.ts @@ -133,10 +133,18 @@ export function captureFeatureUsed( } catch {} } -export function coreFeatureTelemetry(getTelemetry: () => TelemetryService) { +export function coreFeatureTelemetry( + getTelemetry: () => TelemetryService, + isPluginRequest: (context: Context) => boolean = () => false, +) { return async (context: Context, next: Next): Promise => { await next(); - if (context.res.status < 200 || context.res.status >= 300) return; + if ( + context.res.status < 200 || + context.res.status >= 300 || + isPluginRequest(context) + ) + return; const feature = registry.get( `${context.req.method} ${context.req.routePath}`, ); diff --git a/apps/server/test/services/plugins/plugin-replacement.test.ts b/apps/server/test/services/plugins/plugin-replacement.test.ts index b6f35c10c18..3e96dcff00e 100644 --- a/apps/server/test/services/plugins/plugin-replacement.test.ts +++ b/apps/server/test/services/plugins/plugin-replacement.test.ts @@ -15,10 +15,7 @@ import { createPluginService, type PluginService, } from "../../../src/services/plugins/plugin-service.js"; -import { - createNoopTelemetryService, - type TelemetryEvent, -} from "../../../src/services/system/telemetry.js"; +import { createNoopTelemetryService } from "../../../src/services/system/telemetry.js"; import { testLogger } from "../../helpers/test-app.js"; const logger = testLogger as unknown as Logger; @@ -50,7 +47,6 @@ describe("replacement plugins", () => { let db: DbConnection; let workDir: string; let service: PluginService; - let captured: TelemetryEvent[]; function state(id: string) { const plugin = service.list().find((candidate) => candidate.id === id); @@ -63,15 +59,9 @@ describe("replacement plugins", () => { db = createConnection(":memory:"); migrate(db); workDir = await mkdtemp(join(tmpdir(), "bb-plugin-replacement-")); - captured = []; service = createPluginService({ aiServices: createAiServiceRegistry(), - telemetry: { - ...createNoopTelemetryService(), - capture: (event) => { - captured.push(event); - }, - }, + telemetry: createNoopTelemetryService(), db, hub: { getDaemonSessionIdForHost: () => null, @@ -92,7 +82,6 @@ describe("replacement plugins", () => { await service.installPath(await writePathPlugin(workDir, "next")); await service.installPath(await writePathPlugin(workDir, "bystander")); await service.setEnabled("next", false); - captured.length = 0; }); afterEach(async () => { @@ -109,15 +98,10 @@ describe("replacement plugins", () => { expect(state("next")).toEqual({ enabled: true, status: "running" }); expect(state("original")).toEqual({ enabled: false, status: "disabled" }); expect(state("bystander")).toEqual({ enabled: true, status: "running" }); - expect(captured.map((event) => event.name)).toEqual(["plugin_enabled"]); await service.setEnabled("next", false); expect(state("next")).toEqual({ enabled: false, status: "disabled" }); expect(state("original")).toEqual({ enabled: true, status: "running" }); - expect(captured.map((event) => event.name)).toEqual([ - "plugin_enabled", - "plugin_enabled", - ]); }); it("turns the replacement off when the original is turned back on", async () => { diff --git a/apps/server/test/services/plugins/plugin-service.test.ts b/apps/server/test/services/plugins/plugin-service.test.ts index f3512be9fc2..053a3f5e7f3 100644 --- a/apps/server/test/services/plugins/plugin-service.test.ts +++ b/apps/server/test/services/plugins/plugin-service.test.ts @@ -1,3 +1,4 @@ +import { eq } from "drizzle-orm"; import { cp, mkdtemp, @@ -17,6 +18,7 @@ import { createEnvironment, createProject, getInstalledPlugin, + installedPlugins, migrate, noopNotifier, upsertHost, @@ -859,72 +861,158 @@ describe("plugin service", () => { "disabled", ); expect((await tracked.setEnabled("tracked", true))?.status).toBe("running"); - expect( - captured.filter((event) => event.name === "plugin_installed"), - ).toHaveLength(1); + expect(captured).toHaveLength(1); await tracked.stop(); }); - it("reports plugin_enabled only when a disabled plugin is turned on", async () => { + it("counts plugin actions once and classifies installed provenance without exporting identifiers", async () => { const captured: TelemetryEvent[] = []; const tracked = createTelemetryTrackedService(captured); const rootDir = await writePlugin(workDir, { - name: "bb-plugin-enable-tracked", - serverSource: "export default function plugin() {}", + name: "bb-plugin-private-work", + serverSource: `import { z } from "zod"; + export default function plugin(bb) { + bb.cli.register({ name: "private-work", description: "Work", run() { + bb.experimental_recordFeatureUse({ trigger: "schedule" }); + return { exitCode: 0, stdout: "done" }; + }}); + bb.rpc.register({ + read: { input: z.null(), output: z.null() }, + act: { experimental_usage: "action", input: z.null(), output: z.null() } + }, { read: () => null, act: () => { bb.experimental_recordFeatureUse({ trigger: "schedule" }); return null; } }); + }`, }); - const installed = await tracked.installPath(rootDir); - captured.length = 0; - await tracked.setEnabled(installed.id, true); - await tracked.setEnabled(installed.id, false); - await tracked.setEnabled(installed.id, true); - expect(captured).toEqual([ - { - name: "plugin_enabled", + try { + await tracked.installPath(rootDir); + captured.length = 0; + const read = tracked.getRpcHandler("private-work", "read"); + const act = tracked.getRpcHandler("private-work", "act"); + if (read.outcome !== "found" || act.outcome !== "found") + throw new Error("RPC fixture not loaded"); + await tracked.invokeRpcHandler("private-work", "read", read.value, null, { + kind: "client", + }); + expect(captured).toEqual([]); + await tracked.runCliCommand("private-work", [], {}); + await tracked.invokeRpcHandler("private-work", "act", act.value, null, { + kind: "client", + }); + await tracked.invokeRpcHandler("private-work", "act", act.value, null, { + kind: "plugin", + pluginId: "private-caller", + }); + expect(captured).toHaveLength(2); + expect(captured[0]).toEqual({ + name: "feature_used", properties: { - plugin_id: null, - provenance: "direct", - marketplace: null, + feature: "plugin", + feature_scope: "plugin", + interaction: "invoked", + trigger: "unknown", + plugin_source: "local", }, - }, - ]); - await tracked.stop(); - }); - - it("still turns a plugin on when its enable telemetry throws", async () => { - const service = createPluginService({ - aiServices: createAiServiceRegistry(), - db, - hub: { - getDaemonSessionIdForHost: () => null, - notifyPluginSignal: () => 0, - notifySystem: () => {}, - }, - logger, - telemetry: { - ...createNoopTelemetryService(), - capture: (event) => { - if (event.name === "plugin_enabled") { - throw new Error("telemetry unavailable"); - } + }); + const marketplace = { + sourceGitRef: null, + sourceGitSubdirectory: null, + sourceGitCommit: null, + manifestJson: "{}", + statsJson: null, + etag: null, + lastModified: null, + lastSuccessfulRefreshAt: null, + lastAttemptedRefreshAt: null, + lastError: null, + }; + upsertPluginMarketplace(db, { + ...marketplace, + name: "bb-community", + sourceKind: "https", + manifestUrl: "https://getbb.app/marketplace/v2/marketplace.json", + }); + upsertPluginMarketplace(db, { + ...marketplace, + name: "private-team", + sourceKind: "git", + manifestUrl: "https://github.com/private-team/marketplace.git", + }); + const sources = [ + { + sourceKind: "builtin", + provenance: "builtin", + catalogMarketplaceName: null, + expected: "builtin", }, - }, - dataDir: join(workDir, "data"), - appVersion: "0.9.0", - bundledPlugins: [], - loadTimeoutMs: 2000, - }); - const rootDir = await writePlugin(workDir, { - name: "bb-plugin-enable-guarded", - serverSource: "export default function plugin() {}", - }); - const installed = await service.installPath(rootDir); - await service.setEnabled(installed.id, false); - await expect(service.setEnabled(installed.id, true)).resolves.toMatchObject( - { - status: "running", - }, - ); - await service.stop(); + { + sourceKind: "git", + provenance: "catalog", + catalogMarketplaceName: "bb-community", + expected: "public_catalog", + }, + { + sourceKind: "git", + provenance: "catalog", + catalogMarketplaceName: "private-team", + expected: "private_marketplace", + }, + { + sourceKind: "path", + provenance: "catalog", + catalogMarketplaceName: "bb-community", + expected: "local", + }, + { + sourceKind: "git", + provenance: "direct", + catalogMarketplaceName: null, + expected: "unknown", + }, + ] as const; + for (const { expected, ...source } of sources) { + db.update(installedPlugins) + .set(source) + .where(eq(installedPlugins.id, "private-work")) + .run(); + captured.length = 0; + expect(tracked.recordPanelOpen("private-work")).toBe(true); + expect(captured).toEqual([ + { + name: "feature_used", + properties: { + feature: "plugin", + feature_scope: "plugin", + interaction: "opened", + trigger: "unknown", + plugin_source: expected, + }, + }, + ]); + expect(JSON.stringify(captured)).not.toMatch( + /private-work|private-team|github|marketplace.json/, + ); + } + upsertPluginMarketplace(db, { + ...marketplace, + name: "bb-community", + sourceKind: "https", + manifestUrl: "https://private.example/marketplace.json", + }); + db.update(installedPlugins) + .set({ + sourceKind: "git", + provenance: "catalog", + catalogMarketplaceName: "bb-community", + }) + .where(eq(installedPlugins.id, "private-work")) + .run(); + captured.length = 0; + tracked.recordPanelOpen("private-work"); + expect(captured[0]).toMatchObject({ + properties: { plugin_source: "unknown" }, + }); + } finally { + await tracked.stop(); + } }); it("does not report plugin_installed during boot-time reconcile", async () => { diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index b9b75ed20d3..5d9a30aae00 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -4210,3 +4210,9 @@ Stabilization requires exercising reconnect/reload, concurrent deduplication, ro `hosts.experimental_discoverRepos({ hostId })` asks the machine for git repositories under the user's home directory with local activity in the last 30 days, newest first, capped at 10. Each entry has `path`, `name`, `lastActivityAt`, `originUrl`, and `projectId` (the bb project already bound to that path on that machine, or null). `truncated` is true when the three-second walk budget ran out. The walk stops at each repository root, skips dot-directories, common build directories, scratch directories (`tmp`, `temp`, `tmp-*`, `Downloads`), linked worktrees, and submodules. The first-run setup guide and `bb project discover` use it. Host-daemon wire change: the `host.discover_repos` command (protocol 230). Stabilize after deciding whether depth, recency window, and limit should be caller options, and after exercising slow or network-mounted home directories and Windows hosts. + +## Anonymous capability capture + +- `PluginRpcMethodContract.experimental_usage`: optional `action` or `query` classification. Core registers the same metadata for bundled plugins and captures only explicit actions through the shared RPC boundary. Stabilize after plugin authors can classify actions without counting polling or restoration. +- `BbPluginApi.experimental_recordFeatureUse`: records one background work invocation using fixed trigger categories. Core owns source classification, nested-call suppression and opt-out. Automations uses it at actual agent/script execution, never at timer polling. Stabilize after execution and nested-call parity is verified. +- `PluginsArea.experimental_recordPanelOpen`: core panel actions and route navigation use the same endpoint; the server resolves the installed plugin and sends only categorical provenance. Stabilize after panel restoration and explicit opens remain distinguishable. diff --git a/docs/configuration.md b/docs/configuration.md index ddd12266d17..8ae161d56c4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2249,3 +2249,5 @@ Settings. Search scope is remembered in browser local storage separately for new-thread and follow-up composers. Starred text and mentions persist in the plugin database. See the [Prompt Library skill](../plugins/prompt-library/skills/prompt-library/SKILL.md) for CLI and SDK commands. + +Plugin capability usage passes through shared CLI, agent-tool, classified RPC action and explicit panel-open boundaries. Plugin RPC contracts may set `experimental_usage: "action"` or `"query"`; unclassified methods are excluded rather than guessed from names. `bb.experimental_recordFeatureUse({ trigger })` covers actual background work outside those boundaries, including scheduled automations. Polling, load, registration and restored panels do not count. Nested plugin RPC and core SDK calls are attributed to the outer plugin invocation. The server derives `builtin`, `public_catalog`, `private_marketplace`, `local` or `unknown` from installed provenance, including development overrides; the new event contains no plugin identifiers, names or URLs. diff --git a/packages/plugin-sdk/src/backend-contract.ts b/packages/plugin-sdk/src/backend-contract.ts index f0de6d2ea67..e87531b36ca 100644 --- a/packages/plugin-sdk/src/backend-contract.ts +++ b/packages/plugin-sdk/src/backend-contract.ts @@ -2063,7 +2063,9 @@ export interface PluginStatusApi { export type PluginBbSdk = Omit & { threads: Omit< BbSdk["threads"], - "getPluginMetadata" | "updatePluginMetadata" | "experimental_listPluginMetadata" + | "getPluginMetadata" + | "updatePluginMetadata" + | "experimental_listPluginMetadata" > & { getPluginMetadata( args: Omit & { pluginId?: string }, @@ -2087,6 +2089,10 @@ export type PluginBbSdk = Omit & { * against. */ export interface BbPluginApi { + /** Report one background work invocation; core resolves anonymous plugin provenance and applies opt-out. Do not report timers, polling, or startup. */ + experimental_recordFeatureUse(options: { + trigger: "person" | "agent" | "schedule" | "system" | "unknown"; + }): void; /** The plugin's own id (namespaces storage, routes, commands). */ readonly pluginId: string; /** Leveled, plugin-scoped logger. */ diff --git a/packages/plugin-sdk/src/internal/host-policy.ts b/packages/plugin-sdk/src/internal/host-policy.ts index d7c692fa421..74a51555e0f 100644 --- a/packages/plugin-sdk/src/internal/host-policy.ts +++ b/packages/plugin-sdk/src/internal/host-policy.ts @@ -1703,9 +1703,16 @@ function readRpcMethodContract( const description = rpcDescriptionSchema.parse( Reflect.get(value, "experimental_description"), ); - return description === null - ? { input, output } - : { input, output, experimental_description: description }; + const usage = z + .enum(["action", "query"]) + .optional() + .parse(Reflect.get(value, "experimental_usage")); + return { + input, + output, + ...(description === null ? {} : { experimental_description: description }), + ...(usage === undefined ? {} : { experimental_usage: usage }), + }; } export function readRpcPublicationOptions(value: unknown) { @@ -2870,6 +2877,7 @@ export function normalizeWebSocketRouteRegistration( } type RpcRegistrationRecord = { + usage: "action" | "query" | null; publication: ReturnType; inputSchema: StandardSchemaV1; outputSchema: StandardSchemaV1; @@ -2928,6 +2936,7 @@ export function normalizeRpcRegistration( name, { publication: publishRpcMethod(name, methodContract, publicationOptions), + usage: methodContract.experimental_usage ?? null, 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 2fd5ed1cd17..83c6e8c59f2 100644 --- a/packages/plugin-sdk/src/rpc-contract.ts +++ b/packages/plugin-sdk/src/rpc-contract.ts @@ -70,6 +70,7 @@ export interface PluginRpcMethodContract< OutputSchema extends StandardSchemaV1 = StandardSchemaV1, > { readonly experimental_description?: string; + readonly experimental_usage?: "action" | "query"; readonly input: InputSchema; readonly output: OutputSchema; } diff --git a/packages/plugin-sdk/src/testing/fake-plugin-host.ts b/packages/plugin-sdk/src/testing/fake-plugin-host.ts index e0ddd55aedb..31d5dddae1d 100644 --- a/packages/plugin-sdk/src/testing/fake-plugin-host.ts +++ b/packages/plugin-sdk/src/testing/fake-plugin-host.ts @@ -1499,6 +1499,7 @@ function createFakePluginHostInternal( }; const bb: BbPluginApi = { + experimental_recordFeatureUse() {}, pluginId, log, settings, diff --git a/packages/sdk/src/areas/plugins.ts b/packages/sdk/src/areas/plugins.ts index a1cb0b95425..55a77d97edf 100644 --- a/packages/sdk/src/areas/plugins.ts +++ b/packages/sdk/src/areas/plugins.ts @@ -288,6 +288,7 @@ export interface PluginUpdateJobsArea { } export interface PluginsArea { + experimental_recordPanelOpen(args: PluginIdArgs): Promise; experimental_discoverRpc( args?: PluginRpcDiscoveryArgs, ): Promise; @@ -612,6 +613,13 @@ export function createPluginsArea(args: CreateSdkAreaArgs): PluginsArea { { signal: input.signal }, ); }, + async experimental_recordPanelOpen(input) { + await requestParsed( + pluginPath(input.pluginId, "/usage"), + z.object({ ok: z.literal(true) }), + jsonInit("POST", {}), + ); + }, async callRpc(input) { const envelope = await requestParsed( pluginPath(input.pluginId, `/rpc/${encodeURIComponent(input.method)}`), diff --git a/plugins/account-pool/src/rpc.ts b/plugins/account-pool/src/rpc.ts index a69e030d802..ae63832b0f7 100644 --- a/plugins/account-pool/src/rpc.ts +++ b/plugins/account-pool/src/rpc.ts @@ -29,6 +29,7 @@ import type { CodexDeviceLogin } from "./codex-device-login.js"; export const accountPoolRpcContract = defineRpcContract({ "account.add": { + experimental_usage: "action", input: accountAddInputSchema, output: accountSchema, }, @@ -37,30 +38,37 @@ export const accountPoolRpcContract = defineRpcContract({ output: z.array(accountSummarySchema), }, "account.remove": { + experimental_usage: "action", input: accountIdInputSchema, output: z.object({ removed: z.boolean() }).strict(), }, "account.enable": { + experimental_usage: "action", input: accountIdInputSchema, output: z.object({ account: accountSchema.nullable() }).strict(), }, "account.disable": { + experimental_usage: "action", input: accountIdInputSchema, output: z.object({ account: accountSchema.nullable() }).strict(), }, "account.setPriority": { + experimental_usage: "action", input: accountPriorityInputSchema, output: z.object({ account: accountSchema.nullable() }).strict(), }, "account.reorder": { + experimental_usage: "action", input: accountReorderInputSchema, output: z.null(), }, "account.refreshUsage": { + experimental_usage: "action", input: z.object({ accountId: z.string().uuid() }).strict(), output: z.object({ account: accountSummarySchema.nullable() }).strict(), }, "routing.set": { + experimental_usage: "action", input: routingSetInputSchema, output: z .object({ provider: z.enum(["claude", "codex"]), enabled: z.boolean() }) @@ -71,18 +79,22 @@ export const accountPoolRpcContract = defineRpcContract({ output: accountPoolConfigSchema, }, "config.set": { + experimental_usage: "action", input: accountPoolConfigSetInputSchema, output: accountPoolConfigSchema, }, "login.start": { + experimental_usage: "action", input: z.null(), output: loginStartSchema, }, "login.complete": { + experimental_usage: "action", input: loginCompleteInputSchema, output: accountSchema, }, "codexLogin.start": { + experimental_usage: "action", input: z.null(), output: codexLoginStartSchema, }, @@ -91,6 +103,7 @@ export const accountPoolRpcContract = defineRpcContract({ output: codexLoginPollSchema, }, "codexLogin.cancel": { + experimental_usage: "action", input: codexLoginPollInputSchema, output: codexLoginCancelSchema, }, @@ -103,10 +116,12 @@ export const accountPoolRpcContract = defineRpcContract({ output: routedThreadStatusListSchema, }, "token.rotate": { + experimental_usage: "action", input: tokenRotateInputSchema, output: hubTokenSummarySchema, }, "bypass.set": { + experimental_usage: "action", input: bypassInputSchema, output: bypassInputSchema, }, diff --git a/plugins/agent-annotations/server.ts b/plugins/agent-annotations/server.ts index 4c6de8fe345..aa78231fe09 100644 --- a/plugins/agent-annotations/server.ts +++ b/plugins/agent-annotations/server.ts @@ -11,10 +11,12 @@ const STORAGE_PREFIX = "annotation:"; export const agentAnnotationsRpcContract = defineRpcContract({ update: { + experimental_usage: "action", input: annotationUpdateSchema, output: z.object({ id: z.string() }).strict(), }, save: { + experimental_usage: "action", input: annotationRecordSchema, output: z.object({ id: z.string() }).strict(), }, diff --git a/plugins/automations/package.json b/plugins/automations/package.json index 39b568e1dcc..8d063cc9382 100644 --- a/plugins/automations/package.json +++ b/plugins/automations/package.json @@ -6,7 +6,7 @@ "type": "module", "engines": { "bb": ">=0.0", - "bbPluginSdk": ">=0.6.0" + "bbPluginSdk": ">=0.6.44" }, "bb": { "name": "Automations", diff --git a/plugins/automations/src/rpc.ts b/plugins/automations/src/rpc.ts index e3ffa96a515..1165de02f5b 100644 --- a/plugins/automations/src/rpc.ts +++ b/plugins/automations/src/rpc.ts @@ -31,26 +31,32 @@ export const automationRpcContract = defineRpcContract({ output: automationDetailReadResultSchema, }, automations_create: { + experimental_usage: "action", input: createAutomationInputSchema, output: automationDetailResponseSchema, }, automations_update: { + experimental_usage: "action", input: updateAutomationInputSchema, output: automationDetailResponseSchema, }, automations_delete: { + experimental_usage: "action", input: projectAutomationInputSchema, output: z.object({ ok: z.literal(true) }).strict(), }, automations_pause: { + experimental_usage: "action", input: projectAutomationInputSchema, output: automationResponseSchema, }, automations_resume: { + experimental_usage: "action", input: projectAutomationInputSchema, output: automationResponseSchema, }, automations_run: { + experimental_usage: "action", input: runAutomationInputSchema, output: automationRunRpcResponseSchema, }, diff --git a/plugins/automations/src/run.ts b/plugins/automations/src/run.ts index 43d4e87482f..c7211cd265d 100644 --- a/plugins/automations/src/run.ts +++ b/plugins/automations/src/run.ts @@ -33,10 +33,16 @@ type AgentThreadsSdk = { args: Parameters[0], ): Promise; }; -export type AgentRunApi = Pick & { +export type AgentRunApi = Pick< + BbPluginApi, + "realtime" | "log" | "experimental_recordFeatureUse" +> & { sdk: { threads: AgentThreadsSdk }; }; -export type ScriptRunApi = Pick & { +export type ScriptRunApi = Pick< + BbPluginApi, + "realtime" | "log" | "experimental_recordFeatureUse" +> & { sdk: { projects: ProjectsSdk }; }; @@ -109,6 +115,9 @@ export async function executeAgentRun( db: Db, args: AgentRunArgs, ): Promise { + bb.experimental_recordFeatureUse({ + trigger: args.run.trigger === "manual" ? "unknown" : "schedule", + }); try { if (args.automation.targetThreadId !== null) { await reuseTargetThreadForRun(bb, db, { @@ -269,6 +278,9 @@ export async function executeScriptRun( resolveWorkingDirectory: ScriptWorkingDirectoryResolver; }, ): Promise { + bb.experimental_recordFeatureUse({ + trigger: args.run.trigger === "manual" ? "unknown" : "schedule", + }); try { const scriptFile = args.execution.scriptFile; if (scriptFile === undefined) { diff --git a/plugins/bb-account/src/contract.ts b/plugins/bb-account/src/contract.ts index cdf373d39b3..00e31774f8c 100644 --- a/plugins/bb-account/src/contract.ts +++ b/plugins/bb-account/src/contract.ts @@ -142,6 +142,7 @@ export const accountPrivateRpcContract = defineRpcContract({ output: accountStatusSchema, }, "login.start": { + experimental_usage: "action", input: z.object({ baseUrl: baseUrlOverrideSchema }).strict(), output: loginViewSchema, }, @@ -152,10 +153,12 @@ export const accountPrivateRpcContract = defineRpcContract({ output: loginPollOutputSchema, }, "login.cancel": { + experimental_usage: "action", input: z.object({ loginId: z.string().min(1) }).strict(), output: z.object({ login: loginViewSchema.nullable() }).strict(), }, redeemCode: { + experimental_usage: "action", input: z .object({ code: z.string().trim().min(1).max(64), @@ -165,6 +168,7 @@ export const accountPrivateRpcContract = defineRpcContract({ output: accountStatusSchema, }, signOut: { + experimental_usage: "action", input: emptyInputSchema, output: signOutResultSchema, }, diff --git a/plugins/bb-ai/src/server.ts b/plugins/bb-ai/src/server.ts index e7a683f203a..e1d1779df35 100644 --- a/plugins/bb-ai/src/server.ts +++ b/plugins/bb-ai/src/server.ts @@ -127,6 +127,7 @@ export type BbAiOverview = z.infer; export const bbAiRpcContract = defineRpcContract({ overview: { input: z.null(), output: overviewSchema }, setEnabled: { + experimental_usage: "action", experimental_description: "Turn bb cloud on or off. While off, bb cloud reports not ready and sends nothing to getbb.app.", input: z.object({ enabled: z.boolean() }).strict(), diff --git a/plugins/browser-automation/contracts.ts b/plugins/browser-automation/contracts.ts index d173e197f7a..39b3339bdb1 100644 --- a/plugins/browser-automation/contracts.ts +++ b/plugins/browser-automation/contracts.ts @@ -85,9 +85,17 @@ export const rpcContract = defineRpcContract({ }, run: { input: runSchema, output: outputSchema }, pages: { input: ownedSchema, output: outputSchema }, - screenshot: { input: screenshotSchema, output: outputSchema }, + screenshot: { + experimental_usage: "action", + input: screenshotSchema, + output: outputSchema, + }, preview: { input: previewSchema, output: previewOutputSchema }, - stop: { input: ownedSchema, output: sessionSchema }, + stop: { + experimental_usage: "action", + input: ownedSchema, + output: sessionSchema, + }, close: { input: ownedSchema, output: sessionSchema }, }); export const runtimeStateSchema = z.discriminatedUnion("status", [ @@ -104,6 +112,7 @@ export type RuntimeState = z.infer; export const hostContract = defineRpcContract({ prepare: { input: z.object({}).strict(), output: runtimeStateSchema }, open: { + experimental_usage: "action", input: z .object({ sessionId: sessionIdSchema, @@ -115,6 +124,7 @@ export const hostContract = defineRpcContract({ output: z.null(), }, run: { + experimental_usage: "action", input: z .object({ sessionId: sessionIdSchema, @@ -136,6 +146,7 @@ export const hostContract = defineRpcContract({ output: z.object({ frame: previewFrameSchema.nullable() }).strict(), }, close: { + experimental_usage: "action", input: z.object({ sessionId: sessionIdSchema }).strict(), output: z.null(), }, diff --git a/plugins/browser-automation/package.json b/plugins/browser-automation/package.json index 27fe5fc4bd4..adbb46554c3 100644 --- a/plugins/browser-automation/package.json +++ b/plugins/browser-automation/package.json @@ -6,7 +6,7 @@ "description": "Let BB control the in-app browser and browsers on connected machines, with inline previews for headless sessions.", "engines": { "bb": ">=0.41", - "bbPluginSdk": ">=0.6.44" + "bbPluginSdk": ">=0.4.102" }, "bb": { "name": "Browser Automation", diff --git a/plugins/browser-automation/server.test.ts b/plugins/browser-automation/server.test.ts index 6957b2c4153..632def1f409 100644 --- a/plugins/browser-automation/server.test.ts +++ b/plugins/browser-automation/server.test.ts @@ -31,10 +31,6 @@ async function setup() { host.harness.sdk.stub("threads.get", async () => makeThreadResponse({ id: "thread-test" }), ); - host.harness.sdk.stub( - "system.experimental_recordTelemetryEvent", - async () => ({ ok: true }), - ); host.harness.sdk.stub( "experimental_desktopBrowsers.listInstances", async () => ({ @@ -170,68 +166,6 @@ describe("server session ownership", () => { }, ); - it("reports each opened session's surface and whether an agent, a user, or the SDK opened it", async () => { - const h = await setup(); - h.harness.sdk.stub("hosts.list", async () => [ - makeHostResponse({ id: "local-host", name: "Lab workstation" }), - ]); - try { - await h.open(); - const cli = await h.harness.behavior.runCli( - [ - "open", - "--backend", - "local", - "--machine", - "local-host", - "--headless", - "--json", - ], - { threadId: "thread-test" }, - ); - expect(cli.exitCode, cli.stderr).toBe(0); - const terminal = await h.harness.behavior.runCli( - [ - "open", - "--backend", - "local", - "--machine", - "local-host", - "--headless", - "--thread", - "thread-test", - "--json", - ], - {}, - ); - expect(terminal.exitCode, terminal.stderr).toBe(0); - expect( - h.harness.sdk.callsTo("system.experimental_recordTelemetryEvent"), - ).toEqual([ - [ - { - name: "browser_session_started", - properties: { surface: "desktop", initiated_by: "sdk" }, - }, - ], - [ - { - name: "browser_session_started", - properties: { surface: "headless", initiated_by: "agent" }, - }, - ], - [ - { - name: "browser_session_started", - properties: { surface: "headless", initiated_by: "user" }, - }, - ], - ]); - } finally { - await h.harness.lifecycle.dispose(); - } - }); - it("prefers an exact machine ID over a matching name", async () => { const h = await setup(); h.harness.sdk.stub("hosts.list", async () => [ diff --git a/plugins/browser-automation/server.ts b/plugins/browser-automation/server.ts index 6bd1e82f964..1a3a3810b1b 100644 --- a/plugins/browser-automation/server.ts +++ b/plugins/browser-automation/server.ts @@ -21,10 +21,6 @@ import { type BrowserCliRequest, } from "./cli.js"; import { previewDirective } from "./preview-directive.js"; -import { - recordBrowserSessionStarted, - type BrowserSessionInitiator, -} from "./telemetry.js"; const desktopSchema = z .object({ @@ -191,7 +187,6 @@ export default async function browserAutomationPlugin(bb: BbPluginApi) { async function open( input: z.output, signal: AbortSignal, - initiatedBy: BrowserSessionInitiator, ): Promise { let threadLifecycle = threadLifecycles.get(input.threadId); if (!threadLifecycle) { @@ -335,10 +330,6 @@ export default async function browserAutomationPlugin(bb: BbPluginApi) { }), ); } - void recordBrowserSessionStarted(bb, { - surface: session.backend === "local" ? "headless" : "desktop", - initiated_by: initiatedBy, - }); return session; } catch (error) { await finish(record, "closed").catch(() => {}); @@ -409,10 +400,9 @@ export default async function browserAutomationPlugin(bb: BbPluginApi) { } function handlers( signal: AbortSignal, - initiatedBy: BrowserSessionInitiator, ): PluginRpcHandlers { return { - open: (input) => open(input, signal, initiatedBy), + open: (input) => open(input, signal), async list({ threadId }) { const records = await Promise.all( ( @@ -450,14 +440,13 @@ export default async function browserAutomationPlugin(bb: BbPluginApi) { finish(await owned(input.threadId, input.sessionId), "closed"), }; } - bb.rpc.register(rpcContract, handlers(lifecycle.signal, "sdk")); + bb.rpc.register(rpcContract, handlers(lifecycle.signal)); function dispatch( method: BrowserCliMethod, input: unknown, signal: AbortSignal, - initiatedBy: BrowserSessionInitiator, ) { - const h = handlers(signal, initiatedBy); + const h = handlers(signal); switch (method) { case "open": return h.open(rpcContract.open.input.parse(input)); @@ -538,7 +527,6 @@ export default async function browserAutomationPlugin(bb: BbPluginApi) { context.signal ?? new AbortController().signal, lifecycle.signal, ]), - context.threadId === undefined ? "user" : "agent", ); const output = rpcContract.run.output.safeParse(result); const previewed = rpcContract.preview.output.safeParse(result); diff --git a/plugins/browser-automation/telemetry.ts b/plugins/browser-automation/telemetry.ts deleted file mode 100644 index 016b932d7a4..00000000000 --- a/plugins/browser-automation/telemetry.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { BbPluginApi } from "@get-bb/plugin-sdk"; - -export type BrowserSessionInitiator = "agent" | "user" | "sdk"; - -export async function recordBrowserSessionStarted( - bb: Pick, - properties: { - surface: "headless" | "desktop"; - initiated_by: BrowserSessionInitiator; - }, -): Promise { - try { - await bb.sdk.system.experimental_recordTelemetryEvent({ - name: "browser_session_started", - properties, - }); - } catch {} -} diff --git a/plugins/concurrency-limit/server.ts b/plugins/concurrency-limit/server.ts index 58685c434d7..35ac543e44a 100644 --- a/plugins/concurrency-limit/server.ts +++ b/plugins/concurrency-limit/server.ts @@ -88,6 +88,7 @@ export const concurrencyLimitRpcContract = defineRpcContract({ output: hostConfigurationSchema, }, setConfiguration: { + experimental_usage: "action", input: configurationSchema, output: hostConfigurationSchema, }, diff --git a/plugins/connect/src/rpc.ts b/plugins/connect/src/rpc.ts index 1fb4047bede..555827f2001 100644 --- a/plugins/connect/src/rpc.ts +++ b/plugins/connect/src/rpc.ts @@ -87,11 +87,17 @@ const machineCodeSchema: z.ZodType = z export const connectRpcContract = defineRpcContract({ status: { input: z.null(), output: connectStatusSchema }, setRemoteAccess: { + experimental_usage: "action", input: z.object({ enabled: z.boolean() }).strict(), output: connectStatusSchema, }, - expose: { input: portInputSchema, output: shareListingSchema }, + expose: { + experimental_usage: "action", + input: portInputSchema, + output: shareListingSchema, + }, unexpose: { + experimental_usage: "action", input: portInputSchema, output: z .object({ @@ -103,6 +109,7 @@ export const connectRpcContract = defineRpcContract({ .strict(), }, unexposeAll: { + experimental_usage: "action", input: z.object({ hostId: z.string().min(1) }).strict(), output: z.object({ removed: z.number().int().nonnegative() }).strict(), }, @@ -111,9 +118,18 @@ export const connectRpcContract = defineRpcContract({ input: z.null(), output: listAccountServersResultSchema, }, - createDesktopSession: { input: z.null(), output: desktopSessionSchema }, - createMachineCode: { input: z.null(), output: machineCodeSchema }, + createDesktopSession: { + experimental_usage: "action", + input: z.null(), + output: desktopSessionSchema, + }, + createMachineCode: { + experimental_usage: "action", + input: z.null(), + output: machineCodeSchema, + }, revokeMachine: { + experimental_usage: "action", input: revokeMachineInputSchema, output: z.object({ ok: z.literal(true) }).strict(), }, diff --git a/plugins/docs/server.ts b/plugins/docs/server.ts index 74745ac2855..e2eb9e31a38 100644 --- a/plugins/docs/server.ts +++ b/plugins/docs/server.ts @@ -264,6 +264,7 @@ export const docsRpcContract = defineRpcContract({ output: proposalSchema.nullable(), }, proposeNote: { + experimental_usage: "action", input: z .object({ vaultId: vaultIdSchema, @@ -276,6 +277,7 @@ export const docsRpcContract = defineRpcContract({ output: proposalSchema, }, updateProposal: { + experimental_usage: "action", input: z .object({ vaultId: vaultIdSchema, @@ -287,6 +289,7 @@ export const docsRpcContract = defineRpcContract({ output: proposalSchema, }, resolveProposal: { + experimental_usage: "action", input: z .object({ vaultId: vaultIdSchema, @@ -311,6 +314,7 @@ export const docsRpcContract = defineRpcContract({ .strict(), }, syncApply: { + experimental_usage: "action", input: z .object({ vaultId: vaultIdSchema, @@ -379,6 +383,7 @@ export const docsRpcContract = defineRpcContract({ output: fileReadSchema, }, saveNote: { + experimental_usage: "action", input: z .object({ vaultId: vaultIdSchema, @@ -390,6 +395,7 @@ export const docsRpcContract = defineRpcContract({ output: fileWriteSchema, }, createNote: { + experimental_usage: "action", input: z .object({ vaultId: vaultIdSchema, @@ -401,6 +407,7 @@ export const docsRpcContract = defineRpcContract({ output: pathResultSchema, }, deletePath: { + experimental_usage: "action", input: z .object({ vaultId: vaultIdSchema, @@ -411,10 +418,12 @@ export const docsRpcContract = defineRpcContract({ output: okResultSchema, }, createFolder: { + experimental_usage: "action", input: z.object({ vaultId: vaultIdSchema, path: vaultPathSchema }).strict(), output: pathResultSchema, }, reorderFiles: { + experimental_usage: "action", input: z .object({ vaultId: vaultIdSchema, @@ -425,6 +434,7 @@ export const docsRpcContract = defineRpcContract({ output: z.object({ paths: z.array(vaultPathSchema) }).strict(), }, movePath: { + experimental_usage: "action", input: z .object({ vaultId: vaultIdSchema, @@ -435,10 +445,12 @@ export const docsRpcContract = defineRpcContract({ output: pathResultSchema, }, renameToTitle: { + experimental_usage: "action", input: z.object({ vaultId: vaultIdSchema, path: vaultPathSchema }).strict(), output: pathResultSchema, }, createVault: { + experimental_usage: "action", input: z .object({ name: z.string().min(1), @@ -449,10 +461,12 @@ export const docsRpcContract = defineRpcContract({ output: vaultSchema, }, removeVault: { + experimental_usage: "action", input: z.object({ vaultId: z.string().min(1) }).strict(), output: okResultSchema, }, uploadAttachment: { + experimental_usage: "action", input: z .object({ vaultId: vaultIdSchema, @@ -474,6 +488,7 @@ export const docsRpcContract = defineRpcContract({ output: previewSchema, }, openFile: { + experimental_usage: "action", input: z .object({ source: openerSourceSchema, path: z.string().min(1) }) .strict(), @@ -486,6 +501,7 @@ export const docsRpcContract = defineRpcContract({ .strict(), }, saveOpenedFile: { + experimental_usage: "action", input: z .object({ source: openerSourceSchema, diff --git a/plugins/environment-modal-sandbox/account.ts b/plugins/environment-modal-sandbox/account.ts index a3911980dca..8fc5e0e305c 100644 --- a/plugins/environment-modal-sandbox/account.ts +++ b/plugins/environment-modal-sandbox/account.ts @@ -40,24 +40,46 @@ const definitionSchema = z.object({ }); export const modalRpcContract = defineRpcContract({ "machine.inspect": { input: machineInput, output: machineOutput }, - "image.build": { input: z.object({}).strict(), output: buildOutput }, - "sandbox.run": { input: z.object({}).strict(), output: runOutput }, - "sandbox.exec": { input: execInput, output: execOutput }, - "sandbox.stop": { input: sandboxInput, output: sandboxInput }, + "image.build": { + experimental_usage: "action", + input: z.object({}).strict(), + output: buildOutput, + }, + "sandbox.run": { + experimental_usage: "action", + input: z.object({}).strict(), + output: runOutput, + }, + "sandbox.exec": { + experimental_usage: "action", + input: execInput, + output: execOutput, + }, + "sandbox.stop": { + experimental_usage: "action", + input: sandboxInput, + output: sandboxInput, + }, "image.definition": { input: z.object({}).strict(), output: definitionSchema, }, "image.set": { + experimental_usage: "action", input: z.object({ dockerfile: dockerfileSchema }).strict(), output: definitionSchema, }, - "image.reset": { input: z.object({}).strict(), output: definitionSchema }, + "image.reset": { + experimental_usage: "action", + input: z.object({}).strict(), + output: definitionSchema, + }, "launch.options": { input: z.object({}).strict(), output: modalLaunchOptionsSchema, }, "launch.options.set": { + experimental_usage: "action", input: modalLaunchOptionsSchema, output: modalLaunchOptionsSchema, }, diff --git a/plugins/github/server.ts b/plugins/github/server.ts index 47fd0bc1bd9..50a74486b57 100644 --- a/plugins/github/server.ts +++ b/plugins/github/server.ts @@ -144,7 +144,11 @@ export const githubRpcContract = defineRpcContract({ }) .strict(), }, - refresh: { input: z.null(), output: syncResultSchema }, + refresh: { + experimental_usage: "action", + input: z.null(), + output: syncResultSchema, + }, listItems: { input: z .object({ @@ -170,12 +174,14 @@ export const githubRpcContract = defineRpcContract({ output: z.object({ labels: z.array(z.string().min(1)) }).strict(), }, setIssueState: { + experimental_usage: "action", input: itemInputSchema .extend({ state: z.enum(["open", "closed"]) }) .strict(), output: okResultSchema, }, setAssignees: { + experimental_usage: "action", input: itemInputSchema .extend({ assignees: z.array(z.string().min(1)) }) .strict(), @@ -184,6 +190,7 @@ export const githubRpcContract = defineRpcContract({ .strict(), }, setLabels: { + experimental_usage: "action", input: itemInputSchema.extend({ labels: z.array(z.string()) }).strict(), output: z .object({ ok: z.literal(true), labels: z.array(z.string().min(1)) }) @@ -216,6 +223,7 @@ export const githubRpcContract = defineRpcContract({ output: z.object({ pull: pullSchema }).strict(), }, commentPull: { + experimental_usage: "action", input: itemInputSchema.extend({ body: nonBlankStringSchema }).strict(), output: okResultSchema, }, @@ -235,10 +243,12 @@ export const githubRpcContract = defineRpcContract({ .strict(), }, commentIssue: { + experimental_usage: "action", input: itemInputSchema.extend({ body: nonBlankStringSchema }).strict(), output: okResultSchema, }, createIssue: { + experimental_usage: "action", input: z .object({ repo: repoNameSchema, @@ -251,10 +261,12 @@ export const githubRpcContract = defineRpcContract({ .strict(), }, startWork: { + experimental_usage: "action", input: itemInputSchema, output: z.object({ threadId: z.string().min(1) }).strict(), }, startReview: { + experimental_usage: "action", input: itemInputSchema, output: z.object({ threadId: z.string().min(1) }).strict(), }, diff --git a/plugins/keep-awake/server.ts b/plugins/keep-awake/server.ts index 469e7ecb51e..281c893868f 100644 --- a/plugins/keep-awake/server.ts +++ b/plugins/keep-awake/server.ts @@ -54,6 +54,7 @@ export const keepAwakeRpcContract = defineRpcContract({ output: hostConfigurationSchema, }, setConfiguration: { + experimental_usage: "action", input: keepAwakeConfigurationSchema, output: hostConfigurationSchema, }, diff --git a/plugins/memory/server.ts b/plugins/memory/server.ts index c66234156f9..6e3ce149b81 100644 --- a/plugins/memory/server.ts +++ b/plugins/memory/server.ts @@ -77,10 +77,12 @@ export const memoryRpcContract = defineRpcContract({ output: z.object({ memories: z.array(memoryRecordSchema) }).strict(), }, updateMemory: { + experimental_usage: "action", input: memoryUpdateInputSchema, output: z.object({ memory: memoryRecordSchema }).strict(), }, deleteMemory: { + experimental_usage: "action", input: z .object({ id: z.string(), diff --git a/plugins/monaco-editor/server.ts b/plugins/monaco-editor/server.ts index 92d9702c69a..5f00fd8964d 100644 --- a/plugins/monaco-editor/server.ts +++ b/plugins/monaco-editor/server.ts @@ -58,6 +58,7 @@ export const rpcContract = defineRpcContract({ }), }, write: { + experimental_usage: "action", input: fileSchema.extend({ content: z.string(), expectedSha256: z.string().nullable(), diff --git a/plugins/plugin-api-docs/src/surfaces.ts b/plugins/plugin-api-docs/src/surfaces.ts index f014b9b3771..af56722fa8e 100644 --- a/plugins/plugin-api-docs/src/surfaces.ts +++ b/plugins/plugin-api-docs/src/surfaces.ts @@ -906,6 +906,8 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ "Use RPC, HTTP, WebSocket, and realtime messages to connect the plugin. With this, a plugin can:", bullets: [ "Call its server from its UI with schema-checked RPC", + "Classify RPC actions for anonymous capability counts; queries and unclassified methods are excluded", + "Record background work with fixed trigger categories; core resolves plugin source and honors opt-out", "Receive webhooks and serve HTTP or WebSocket routes", "Push live updates to open bb windows", "Publish RPC methods other plugins can discover", @@ -913,6 +915,9 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ apiSymbols: [ "PluginRpc", "PluginRpcMethodContract", + "PluginRpcMethodContract.experimental_usage", + "BbPluginApi.experimental_recordFeatureUse", + "PluginBbSdk.plugins.experimental_recordPanelOpen", "ExperimentalPluginRpcHandlersWithContext", "ExperimentalPluginRpcHandlerContext", "ExperimentalPluginRpcCaller", diff --git a/plugins/prompt-library/contract.ts b/plugins/prompt-library/contract.ts index c80237fe03b..fe711b9c236 100644 --- a/plugins/prompt-library/contract.ts +++ b/plugins/prompt-library/contract.ts @@ -155,6 +155,7 @@ export const promptLibraryRpcContract = defineRpcContract({ .strict(), }, star: { + experimental_usage: "action", input: z .object({ prompt: draftSchema.refine( @@ -166,10 +167,12 @@ export const promptLibraryRpcContract = defineRpcContract({ output: z.object({ id: z.string() }).strict(), }, unstar: { + experimental_usage: "action", input: z.object({ id: z.string().min(1) }).strict(), output: z.object({ unstarred: z.boolean() }).strict(), }, markUsed: { + experimental_usage: "action", input: z.object({ id: z.string().min(1) }).strict(), output: z.null(), }, diff --git a/plugins/provider-acp-next/src/registry-service.ts b/plugins/provider-acp-next/src/registry-service.ts index ba9346085d9..994a7820d12 100644 --- a/plugins/provider-acp-next/src/registry-service.ts +++ b/plugins/provider-acp-next/src/registry-service.ts @@ -72,8 +72,16 @@ export const acpRegistryRpcContract = defineRpcContract({ input: z.object({ refresh: z.boolean() }).strict(), output: registryViewSchema, }, - addRegistryAgent: { input: agentIdInputSchema, output: registryViewSchema }, - removeAgent: { input: agentIdInputSchema, output: registryViewSchema }, + addRegistryAgent: { + experimental_usage: "action", + input: agentIdInputSchema, + output: registryViewSchema, + }, + removeAgent: { + experimental_usage: "action", + input: agentIdInputSchema, + output: registryViewSchema, + }, }); const cachedRegistrySchema = z.object({ diff --git a/plugins/push-notifications/contract.ts b/plugins/push-notifications/contract.ts index d10f0a0220f..466fc37fb29 100644 --- a/plugins/push-notifications/contract.ts +++ b/plugins/push-notifications/contract.ts @@ -70,6 +70,7 @@ export const listPushSubscriptionsOutputSchema = z export const pushNotificationsRpcContract = defineRpcContract({ "notifications.test": { + experimental_usage: "action", input: z.object({ channel: clientChannelSchema }).strict(), output: z.object({ ok: z.literal(true) }).strict(), }, @@ -78,10 +79,12 @@ export const pushNotificationsRpcContract = defineRpcContract({ output: listPushSubscriptionsOutputSchema, }, "pushSubscriptions.add": { + experimental_usage: "action", input: addPushSubscriptionInputSchema, output: z.object({ id: z.string().min(1), created: z.boolean() }).strict(), }, "pushSubscriptions.remove": { + experimental_usage: "action", input: removePushSubscriptionInputSchema, output: z.object({ ok: z.literal(true) }).strict(), }, @@ -101,6 +104,7 @@ export const pushNotificationsRpcContract = defineRpcContract({ .strict(), }, "threadNotifications.set": { + experimental_usage: "action", input: z .object({ threadId: z.string().min(1), diff --git a/plugins/side-chat/server.ts b/plugins/side-chat/server.ts index d80cfc354f5..17165e95a2e 100644 --- a/plugins/side-chat/server.ts +++ b/plugins/side-chat/server.ts @@ -67,6 +67,7 @@ function isOwnLiveHiddenFork( export const sideChatRpcContract = defineRpcContract({ createSideChat: { + experimental_usage: "action", input: z .object({ sourceThreadId: z.string().trim().min(1), diff --git a/plugins/storage-retention/src/contract.ts b/plugins/storage-retention/src/contract.ts index 59c3da4d1a0..b64cd3b27f4 100644 --- a/plugins/storage-retention/src/contract.ts +++ b/plugins/storage-retention/src/contract.ts @@ -37,6 +37,7 @@ export type Preview = z.infer; const machineInput = z.object({ hostId: z.string().min(1) }).strict(); export const storageRpc = defineRpcContract({ startCleanup: { + experimental_usage: "action", input: z .object({ hostId: z.string().min(1), @@ -47,21 +48,33 @@ export const storageRpc = defineRpcContract({ }, hosts: { input: z.null(), output: hostStorageListResponseSchema }, host: { input: machineInput, output: hostStorageResponseSchema }, - scanHost: { input: machineInput, output: hostStorageResponseSchema }, - scanAll: { input: z.null(), output: hostStorageListResponseSchema }, + scanHost: { + experimental_usage: "action", + input: machineInput, + output: hostStorageResponseSchema, + }, + scanAll: { + experimental_usage: "action", + input: z.null(), + output: hostStorageListResponseSchema, + }, removeOrphans: { + experimental_usage: "action", input: machineInput, output: hostStorageRemoveOrphansResponseSchema, }, retryWorktreeCleanup: { + experimental_usage: "action", input: machineInput, output: hostStorageRetryWorktreeCleanupResponseSchema, }, startClearLargeFiles: { + experimental_usage: "action", input: z.object({ hostId: z.string().min(1).nullable() }).strict(), output: z.null(), }, clearLargeFiles: { + experimental_usage: "action", input: z.object({ hostId: z.string().min(1).nullable() }).strict(), output: z.object({ clearedFiles: z.number().int().nonnegative(), @@ -69,11 +82,17 @@ export const storageRpc = defineRpcContract({ }), }, clearThread: { + experimental_usage: "action", input: z.object({ threadId: z.string().min(1) }).strict(), output: z.object({ ok: z.literal(true) }), }, - startClearArchivedFiles: { input: machineInput, output: z.null() }, + startClearArchivedFiles: { + experimental_usage: "action", + input: machineInput, + output: z.null(), + }, clearArchivedFiles: { + experimental_usage: "action", input: machineInput, output: z.object({ clearedThreads: z.number().int().nonnegative(), @@ -81,6 +100,7 @@ export const storageRpc = defineRpcContract({ }), }, removeDevInstances: { + experimental_usage: "action", input: z.union([ z.object({ hostId: z.string().min(1), names: z.null() }).strict(), z @@ -101,5 +121,9 @@ export const storageRpc = defineRpcContract({ }, state: { input: z.null(), output: stateSchema }, preview: { input: policySchema, output: previewSchema }, - configure: { input: policySchema, output: stateSchema }, + configure: { + experimental_usage: "action", + input: policySchema, + output: stateSchema, + }, }); diff --git a/plugins/tasks/delegate/contract.ts b/plugins/tasks/delegate/contract.ts index 2451bb3c2af..7fd9c6ce46b 100644 --- a/plugins/tasks/delegate/contract.ts +++ b/plugins/tasks/delegate/contract.ts @@ -6,6 +6,7 @@ const threadIdSchema = z.string().startsWith("thr_"); export const delegationRpcContract = defineRpcContract({ delegate: { + experimental_usage: "action", input: z .object({ taskId: idSchema, @@ -16,10 +17,12 @@ export const delegationRpcContract = defineRpcContract({ output: z.object({ threadId: threadIdSchema }).strict(), }, taskThreadsAttach: { + experimental_usage: "action", input: z.object({ taskId: idSchema, threadId: threadIdSchema }).strict(), output: z.object({ threadId: threadIdSchema }).strict(), }, taskThreadsDetach: { + experimental_usage: "action", input: z.object({ taskId: idSchema, threadId: threadIdSchema }).strict(), output: z.object({ threadId: threadIdSchema }).strict(), }, diff --git a/plugins/tasks/shared/contract.ts b/plugins/tasks/shared/contract.ts index ee4371befaa..37d75d74801 100644 --- a/plugins/tasks/shared/contract.ts +++ b/plugins/tasks/shared/contract.ts @@ -392,6 +392,7 @@ const updatePresetInputSchema = z export const tasksRpcContract = defineRpcContract({ createFolder: { + experimental_usage: "action", input: z .object({ name: nonBlankStringSchema, @@ -401,18 +402,21 @@ export const tasksRpcContract = defineRpcContract({ output: z.object({ folder: folderSchema }).strict(), }, renameFolder: { + experimental_usage: "action", input: z .object({ folderId: idSchema, name: nonBlankStringSchema }) .strict(), output: z.object({ folder: folderSchema }).strict(), }, moveFolder: { + experimental_usage: "action", input: z .object({ folderId: idSchema, parentFolderId: idSchema.nullable() }) .strict(), output: z.object({ folder: folderSchema }).strict(), }, deleteFolder: { + experimental_usage: "action", input: z.object({ folderId: idSchema }).strict(), output: z .object({ @@ -427,6 +431,7 @@ export const tasksRpcContract = defineRpcContract({ output: z.object({ folders: z.array(folderSchema) }).strict(), }, createProject: { + experimental_usage: "action", input: z .object({ name: nonBlankStringSchema, @@ -443,16 +448,19 @@ export const tasksRpcContract = defineRpcContract({ output: z.object({ project: projectSchema }).strict(), }, updateProject: { + experimental_usage: "action", input: updateProjectInputSchema, output: z.object({ project: projectSchema }).strict(), }, renameProjectPrefix: { + experimental_usage: "action", input: z .object({ projectId: idSchema, prefix: projectPrefixSchema }) .strict(), output: projectMutationResultSchema, }, deleteProject: { + experimental_usage: "action", input: z .object({ projectId: idSchema, force: z.boolean().default(false) }) .strict(), @@ -463,6 +471,7 @@ export const tasksRpcContract = defineRpcContract({ output: z.object({ projects: z.array(projectSchema) }).strict(), }, createTask: { + experimental_usage: "action", input: z .object({ projectId: idSchema, @@ -486,10 +495,12 @@ export const tasksRpcContract = defineRpcContract({ output: z.object({ task: taskSchema.nullable() }).strict(), }, updateTask: { + experimental_usage: "action", input: updateTaskInputSchema, output: taskMutationResultSchema, }, deleteTask: { + experimental_usage: "action", input: z.object({ taskId: idSchema }).strict(), output: z.object({ deleted: z.boolean() }).strict(), }, @@ -521,6 +532,7 @@ export const tasksRpcContract = defineRpcContract({ .strict(), }, boardMove: { + experimental_usage: "action", input: z .object({ taskId: idSchema, @@ -533,6 +545,7 @@ export const tasksRpcContract = defineRpcContract({ output: taskMutationResultSchema, }, moveTaskToProject: { + experimental_usage: "action", input: z .object({ taskId: idSchema, @@ -543,6 +556,7 @@ export const tasksRpcContract = defineRpcContract({ output: taskMutationResultSchema, }, createLabel: { + experimental_usage: "action", input: z .object({ projectId: idSchema, @@ -553,10 +567,12 @@ export const tasksRpcContract = defineRpcContract({ output: z.object({ label: labelSchema }).strict(), }, updateLabel: { + experimental_usage: "action", input: updateLabelInputSchema, output: z.object({ label: labelSchema }).strict(), }, deleteLabel: { + experimental_usage: "action", input: z.object({ labelId: idSchema }).strict(), output: z.object({ deleted: z.boolean() }).strict(), }, @@ -565,6 +581,7 @@ export const tasksRpcContract = defineRpcContract({ output: z.object({ labels: z.array(labelSchema) }).strict(), }, createComment: { + experimental_usage: "action", input: z .object({ taskId: idSchema, @@ -592,6 +609,7 @@ export const tasksRpcContract = defineRpcContract({ output: z.object({ attachments: z.array(attachmentSchema) }).strict(), }, deleteAttachment: { + experimental_usage: "action", input: z .object({ attachmentId: idSchema, @@ -614,6 +632,7 @@ export const tasksRpcContract = defineRpcContract({ .strict(), }, createPreset: { + experimental_usage: "action", input: z .object({ name: nonBlankStringSchema, @@ -653,10 +672,12 @@ export const tasksRpcContract = defineRpcContract({ output: z.object({ preset: presetSchema }).strict(), }, updatePreset: { + experimental_usage: "action", input: updatePresetInputSchema, output: z.object({ preset: presetSchema }).strict(), }, deletePreset: { + experimental_usage: "action", input: z.object({ presetId: idSchema }).strict(), output: z.object({ deleted: z.boolean() }).strict(), }, diff --git a/plugins/theme-preview/server.ts b/plugins/theme-preview/server.ts index a291c3e63a6..7b37b6f3fdf 100644 --- a/plugins/theme-preview/server.ts +++ b/plugins/theme-preview/server.ts @@ -37,6 +37,7 @@ const catalogSchema = z export const rpcContract = defineRpcContract({ themeCatalog: { input: z.object({}).strict(), output: catalogSchema }, setTheme: { + experimental_usage: "action", input: z.object({ themeId: z.string().min(1) }).strict(), output: catalogSchema, }, diff --git a/plugins/thread-list/server.ts b/plugins/thread-list/server.ts index 9e4c2904b30..383d8bbf9aa 100644 --- a/plugins/thread-list/server.ts +++ b/plugins/thread-list/server.ts @@ -31,7 +31,9 @@ const preferenceKeySchema = z.enum( const preferenceValuesSchema = z.object( Object.fromEntries( PREFERENCE_KEYS.map((key) => [key, preferenceDefinitions[key].schema]), - ) as { [Key in PreferenceKey]: (typeof preferenceDefinitions)[Key]["schema"] }, + ) as { + [Key in PreferenceKey]: (typeof preferenceDefinitions)[Key]["schema"]; + }, ); export const threadListRpcContract = defineRpcContract({ @@ -40,16 +42,14 @@ export const threadListRpcContract = defineRpcContract({ output: z.object({ preferences: preferenceValuesSchema }).strict(), }, setPreference: { + experimental_usage: "action", input: z.object({ key: preferenceKeySchema, value: z.unknown() }).strict(), - output: z - .object({ key: preferenceKeySchema, value: z.unknown() }) - .strict(), + output: z.object({ key: preferenceKeySchema, value: z.unknown() }).strict(), }, resetPreference: { + experimental_usage: "action", input: z.object({ key: preferenceKeySchema }).strict(), - output: z - .object({ key: preferenceKeySchema, value: z.unknown() }) - .strict(), + output: z.object({ key: preferenceKeySchema, value: z.unknown() }).strict(), }, }); @@ -149,7 +149,9 @@ export async function migrateFromUiPreferences( if (legacy === undefined) continue; const parsed = parsePreferenceValue(key, legacy.value); if (!parsed.success) continue; - if (JSON.stringify(parsed.value) === JSON.stringify(getPreferenceDefault(key))) { + if ( + JSON.stringify(parsed.value) === JSON.stringify(getPreferenceDefault(key)) + ) { continue; } await bb.storage.kv.set(kvKey(key), parsed.value); @@ -198,7 +200,8 @@ export default async function threadListPlugin(bb: BbPluginApi) { bb.cli.register( defineCli({ name: bb.pluginId, - summary: "Inspect and change the sidebar thread list's layout preferences", + summary: + "Inspect and change the sidebar thread list's layout preferences", description: "Organization mode, sort, section order, hidden groups, and collapsed groups for bb's sidebar thread list. Values are JSON; a bare word is read as a string.", commands: { @@ -242,7 +245,8 @@ export default async function threadListPlugin(bb: BbPluginApi) { { name: "key", description: "Preference name", required: true }, { name: "value", - description: 'JSON value, e.g. \'"machine"\' or \'["pinned","threads"]\'', + description: + 'JSON value, e.g. \'"machine"\' or \'["pinned","threads"]\'', required: true, }, ], diff --git a/plugins/tips/contract.ts b/plugins/tips/contract.ts index 90e5aca3943..280bdf02422 100644 --- a/plugins/tips/contract.ts +++ b/plugins/tips/contract.ts @@ -143,15 +143,25 @@ export const FEED_SIZE = 3; export const tipsRpcContract = defineRpcContract({ current: { input: tipFeedInputSchema, output: tipFeedOutputSchema }, hide: { + experimental_usage: "action", input: z.object({ hidden: z.boolean() }).strict(), output: okSchema, }, setEnabled: { + experimental_usage: "action", input: z.object({ enabled: z.boolean() }).strict(), output: okSchema, }, - dismiss: { input: tipIdInputSchema, output: okSchema }, - act: { input: tipIdInputSchema, output: okSchema }, + dismiss: { + experimental_usage: "action", + input: tipIdInputSchema, + output: okSchema, + }, + act: { + experimental_usage: "action", + input: tipIdInputSchema, + output: okSchema, + }, list: { input: z .object({ client: tipClientSchema.nullable(), all: z.boolean() }) @@ -164,7 +174,7 @@ export const tipsRpcContract = defineRpcContract({ }) .strict(), }, - reset: { input: z.null(), output: okSchema }, + reset: { experimental_usage: "action", input: z.null(), output: okSchema }, }); export const TIPS_CHANGED_CHANNEL = "tips-changed"; diff --git a/plugins/workflows/src/ui-contract.ts b/plugins/workflows/src/ui-contract.ts index 9dab82358fa..457a07d7e14 100644 --- a/plugins/workflows/src/ui-contract.ts +++ b/plugins/workflows/src/ui-contract.ts @@ -76,6 +76,7 @@ export const workflowUiRpcContract = defineRpcContract({ output: z.object({ run: workflowRunViewSchema.nullable() }).strict(), }, workflowStopRun: { + experimental_usage: "action", input: z .object({ threadId: z.string().trim().min(1), From 84b2439dca000ea1e68488d532c0ba2d8cd5ccb2 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:14:11 -0700 Subject: [PATCH 08/11] Document shared capability capture and align SDK consumers --- .../src/__tests__/public-types.test.ts | 1 + packages/sdk/test/public-types.test.ts | 1 + plugins/automations/src/automations.test.ts | 5 +++++ plugins/automations/src/service.ts | 5 ++++- plugins/plugin-api-docs/src/surfaces.ts | 21 +++++++++++++++---- 5 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/plugin-sdk/src/__tests__/public-types.test.ts b/packages/plugin-sdk/src/__tests__/public-types.test.ts index d0679a22128..85b499699bf 100644 --- a/packages/plugin-sdk/src/__tests__/public-types.test.ts +++ b/packages/plugin-sdk/src/__tests__/public-types.test.ts @@ -16,6 +16,7 @@ import type { } from "../app-contract.js"; type ExpectedBbPluginApiKey = + | "experimental_recordFeatureUse" | "agents" | "background" | "cli" diff --git a/packages/sdk/test/public-types.test.ts b/packages/sdk/test/public-types.test.ts index 55c07d4841b..7f15714e49d 100644 --- a/packages/sdk/test/public-types.test.ts +++ b/packages/sdk/test/public-types.test.ts @@ -300,6 +300,7 @@ type ExpectedHostsKey = | "update"; type ExpectedPluginsKey = + | "experimental_recordPanelOpen" | "experimental_discoverRpc" | "experimental_getSafeMode" | "experimental_setSafeMode" diff --git a/plugins/automations/src/automations.test.ts b/plugins/automations/src/automations.test.ts index 06273d86b72..a7b5f99080e 100644 --- a/plugins/automations/src/automations.test.ts +++ b/plugins/automations/src/automations.test.ts @@ -230,6 +230,7 @@ function createAutomationServiceBb() { }, }, }, + experimental_recordFeatureUse: () => undefined, realtime: { publish: () => undefined }, log: { debug: () => undefined, @@ -364,6 +365,7 @@ describe("startup reconciliation", () => { }, }, }, + experimental_recordFeatureUse: () => undefined, realtime: { publish: (...args: unknown[]) => void published.push(args), }, @@ -856,6 +858,7 @@ describe("automation data access", () => { }, }, }, + experimental_recordFeatureUse: () => undefined, realtime: { publish: () => undefined }, log: { debug: () => undefined, @@ -1290,6 +1293,7 @@ describe("automation service", () => { }, }, }, + experimental_recordFeatureUse: () => undefined, realtime: { publish: () => undefined }, log: { debug: () => undefined, @@ -2232,6 +2236,7 @@ describe("script project context", () => { }), }, }, + experimental_recordFeatureUse: () => undefined, realtime: { publish: () => undefined }, log: { debug: () => undefined, diff --git a/plugins/automations/src/service.ts b/plugins/automations/src/service.ts index 534e43e835d..4b2522dfb8a 100644 --- a/plugins/automations/src/service.ts +++ b/plugins/automations/src/service.ts @@ -67,7 +67,10 @@ import { projectPathForHost, } from "./working-directory.js"; -type ServiceApi = Pick & { +type ServiceApi = Pick< + BbPluginApi, + "realtime" | "log" | "experimental_recordFeatureUse" +> & { sdk: { system: { config(): Promise<{ primaryHostId: string | null }> }; projects: Pick; diff --git a/plugins/plugin-api-docs/src/surfaces.ts b/plugins/plugin-api-docs/src/surfaces.ts index af56722fa8e..dbc5ccacbbf 100644 --- a/plugins/plugin-api-docs/src/surfaces.ts +++ b/plugins/plugin-api-docs/src/surfaces.ts @@ -906,8 +906,6 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ "Use RPC, HTTP, WebSocket, and realtime messages to connect the plugin. With this, a plugin can:", bullets: [ "Call its server from its UI with schema-checked RPC", - "Classify RPC actions for anonymous capability counts; queries and unclassified methods are excluded", - "Record background work with fixed trigger categories; core resolves plugin source and honors opt-out", "Receive webhooks and serve HTTP or WebSocket routes", "Push live updates to open bb windows", "Publish RPC methods other plugins can discover", @@ -916,8 +914,6 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ "PluginRpc", "PluginRpcMethodContract", "PluginRpcMethodContract.experimental_usage", - "BbPluginApi.experimental_recordFeatureUse", - "PluginBbSdk.plugins.experimental_recordPanelOpen", "ExperimentalPluginRpcHandlersWithContext", "ExperimentalPluginRpcHandlerContext", "ExperimentalPluginRpcCaller", @@ -967,6 +963,23 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ "Worktree", ], }, + { + id: "anonymous-usage", + tagline: "Categorical capability use with the existing opt-out", + title: "Anonymous capability usage", + summary: + "Describe explicit work without exporting plugin identities. With this, a plugin can:", + bullets: [ + "Classify RPC methods as actions or queries; polling and unclassified methods do not count", + "Report background work with a fixed person, agent, schedule, system or unknown trigger", + "Use core's shared provenance classification, opt-out and nested-call suppression", + ], + apiSymbols: [ + "BbPluginApi.experimental_recordFeatureUse", + "PluginBbSdk.plugins.experimental_recordPanelOpen", + ], + firstParty: ["Automations"], + }, { id: "thread-events", tagline: "React when threads and environments change", From 2383b9d2406128665aa43f8faecc48a463a7a2db Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:19:57 -0700 Subject: [PATCH 09/11] Preserve anonymous trigger context through plugin work --- apps/server/src/server.ts | 7 +- .../server/src/services/plugins/plugin-api.ts | 10 +- .../plugins/plugin-feature-telemetry.ts | 11 +- .../src/services/plugins/plugin-rpc-caller.ts | 2 + .../src/services/plugins/plugin-runtime.ts | 6 +- .../src/services/system/feature-telemetry.ts | 28 ++- apps/server/src/services/system/telemetry.ts | 13 +- .../plugins/plugin-authoring-docs.test.ts | 1 + .../services/plugins/plugin-service.test.ts | 4 +- docs/api_to_audit.md | 2 +- docs/configuration.md | 2 +- .../src/__tests__/public-types.test.ts | 2 +- packages/plugin-sdk/src/backend-contract.ts | 11 +- .../src/testing/fake-plugin-host.ts | 4 +- plugins/automations/src/automations.test.ts | 15 +- plugins/automations/src/run.ts | 206 +++++++++--------- plugins/automations/src/service.ts | 2 +- plugins/plugin-api-docs/src/surfaces.ts | 7 +- 18 files changed, 198 insertions(+), 135 deletions(-) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index aa8cd54dffe..9b2783efa39 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,3 +1,4 @@ +import { telemetryTriggerSchema } from "@bb/server-contract"; import { coreFeatureTelemetry } from "./services/system/feature-telemetry.js"; import { createPluginUpdateJobs } from "./services/plugins/plugin-update-jobs.js"; import { registerPluginUpdateJobRoutes } from "./routes/plugin-update-jobs.js"; @@ -885,7 +886,11 @@ export function createApp( const caller = pluginService.resolveRpcCaller( context.req.header("x-bb-plugin-caller"), ); - return caller.ok && caller.caller.kind === "plugin"; + return caller.ok && caller.caller.kind === "plugin" + ? (telemetryTriggerSchema.safeParse( + context.req.header("x-bb-plugin-trigger"), + ).data ?? "unknown") + : null; }, ), ); diff --git a/apps/server/src/services/plugins/plugin-api.ts b/apps/server/src/services/plugins/plugin-api.ts index efeed0dca94..d420a98ec01 100644 --- a/apps/server/src/services/plugins/plugin-api.ts +++ b/apps/server/src/services/plugins/plugin-api.ts @@ -476,9 +476,10 @@ function createStagedRegistrations< const PLUGIN_HOST_CALL_MAX_TIMEOUT_MS = 30 * 60_000; export function createPluginApi(options: { - recordFeatureUse?: ( + withFeatureUse?: ( trigger: import("@bb/server-contract").TelemetryTrigger, - ) => void; + run: () => T, + ) => T; pluginId: string; logger: ServerLogger; db: DbConnection; @@ -1324,9 +1325,10 @@ export function createPluginApi(options: { }; const api: BbPluginApi = { - experimental_recordFeatureUse(value) { + experimental_withFeatureUse(value, run) { const parsed = telemetryTriggerSchema.safeParse(value?.trigger); - if (parsed.success) options.recordFeatureUse?.(parsed.data); + if (!parsed.success || !options.withFeatureUse) return run(); + return options.withFeatureUse(parsed.data, run); }, pluginId, log, diff --git a/apps/server/src/services/plugins/plugin-feature-telemetry.ts b/apps/server/src/services/plugins/plugin-feature-telemetry.ts index 7912af9ee93..f6f25ff32e1 100644 --- a/apps/server/src/services/plugins/plugin-feature-telemetry.ts +++ b/apps/server/src/services/plugins/plugin-feature-telemetry.ts @@ -13,7 +13,7 @@ import { import { captureFeatureUsed } from "../system/feature-telemetry.js"; import type { PluginServiceDeps } from "./plugin-service-internal.js"; -const invocation = new AsyncLocalStorage(); +const invocation = new AsyncLocalStorage(); const capabilities: Readonly> = { automations: "automation", "browser-automation": "browser", @@ -98,8 +98,13 @@ export function withPluginUse( trigger: TelemetryTrigger, run: () => T, capture = true, + interaction: "invoked" | "ran" = "invoked", ): T { if (invocation.getStore()) return run(); - if (capture) capturePluginUse(deps, pluginId, "invoked", trigger); - return invocation.run(true, run); + if (capture) capturePluginUse(deps, pluginId, interaction, trigger); + return invocation.run(trigger, run); +} + +export function pluginInvocationTrigger(): TelemetryTrigger { + return invocation.getStore() ?? "unknown"; } diff --git a/apps/server/src/services/plugins/plugin-rpc-caller.ts b/apps/server/src/services/plugins/plugin-rpc-caller.ts index 7aad7f7b34f..ba60159a7ff 100644 --- a/apps/server/src/services/plugins/plugin-rpc-caller.ts +++ b/apps/server/src/services/plugins/plugin-rpc-caller.ts @@ -1,3 +1,4 @@ +import { pluginInvocationTrigger } from "./plugin-feature-telemetry.js"; import { createHash, randomBytes } from "node:crypto"; import { createNodeBbSdk, @@ -63,6 +64,7 @@ export function createPluginRpcCallerSdk(args: { fetch: (input, init) => { const headers = new Headers(init?.headers); headers.set(PLUGIN_RPC_CALLER_HEADER, args.token); + headers.set("x-bb-plugin-trigger", pluginInvocationTrigger()); return timeoutFetch(input, { ...init, headers }); }, }); diff --git a/apps/server/src/services/plugins/plugin-runtime.ts b/apps/server/src/services/plugins/plugin-runtime.ts index 48f75914247..e566952722d 100644 --- a/apps/server/src/services/plugins/plugin-runtime.ts +++ b/apps/server/src/services/plugins/plugin-runtime.ts @@ -1,4 +1,4 @@ -import { capturePluginUse } from "./plugin-feature-telemetry.js"; +import { withPluginUse } from "./plugin-feature-telemetry.js"; import type { MachineEnrollmentService } from "../machines/machine-services.js"; import { AsyncLocalStorage } from "node:async_hooks"; import { @@ -1622,8 +1622,8 @@ export function createPluginRuntime(context: PluginRuntimeContext) { current: {}, }; const handle = createPluginApi({ - recordFeatureUse: (trigger) => - capturePluginUse(deps, row.id, "ran", trigger), + withFeatureUse: (trigger, run) => + withPluginUse(deps, row.id, trigger, run, true, "ran"), pluginId: row.id, logger: deps.logger, db: deps.db, diff --git a/apps/server/src/services/system/feature-telemetry.ts b/apps/server/src/services/system/feature-telemetry.ts index 3f3c0ab7db8..afc6c93c50e 100644 --- a/apps/server/src/services/system/feature-telemetry.ts +++ b/apps/server/src/services/system/feature-telemetry.ts @@ -1,8 +1,10 @@ +import { withTelemetryTrigger } from "./telemetry.js"; import { publicApiRoutes as routes, featureUsedPropertiesSchema, type CoreFeature, type FeatureUsedProperties, + type TelemetryTrigger, } from "@bb/server-contract"; import type { Context, Next } from "hono"; import { resolveRequestAppSurface } from "../../request-context.js"; @@ -122,6 +124,24 @@ register("settings", [ routes.projects.deleteMachineEnvironmentVariable, ]); +for (const route of [ + "POST /plugins/install", + "POST /plugin-catalog/install", + "POST /plugins/:id/update", + "POST /plugins/:id/enable", + "POST /plugins/:id/disable", + "DELETE /plugins/:id", + "PUT /plugins/:id/settings", + "POST /plugins/reload", + "PUT /plugins/safe-mode", + "POST /plugins/cache/prune", + "POST /marketplaces", + "DELETE /marketplaces/:name", +]) { + const [method, path] = route.split(" "); + registry.set(`${method} /api/v1${path}`, "plugin_management"); +} + export function captureFeatureUsed( telemetry: TelemetryService, properties: FeatureUsedProperties, @@ -135,14 +155,16 @@ export function captureFeatureUsed( export function coreFeatureTelemetry( getTelemetry: () => TelemetryService, - isPluginRequest: (context: Context) => boolean = () => false, + pluginTrigger: (context: Context) => TelemetryTrigger | null = () => null, ) { return async (context: Context, next: Next): Promise => { - await next(); + const trigger = pluginTrigger(context); + if (trigger === null) await next(); + else await withTelemetryTrigger(trigger, next); if ( context.res.status < 200 || context.res.status >= 300 || - isPluginRequest(context) + trigger !== null ) return; const feature = registry.get( diff --git a/apps/server/src/services/system/telemetry.ts b/apps/server/src/services/system/telemetry.ts index e42f8dcf54f..69c35e1d3df 100644 --- a/apps/server/src/services/system/telemetry.ts +++ b/apps/server/src/services/system/telemetry.ts @@ -1,3 +1,4 @@ +import type { TelemetryTrigger } from "@bb/server-contract"; import { validWorkTelemetry } from "./work-telemetry-schema.js"; import type { AnalyticsThreadProperties, @@ -163,7 +164,17 @@ function appInstallProperties(install: AppInstall | null) { return { install_kind: "source", source_origin: install.origin }; } -export function currentTelemetryTrigger(): "person" | "unknown" { +const triggerStorage = new AsyncLocalStorage(); +export function withTelemetryTrigger( + trigger: TelemetryTrigger, + run: () => T, +): T { + return triggerStorage.run(trigger, run); +} + +export function currentTelemetryTrigger(): TelemetryTrigger { + const trigger = triggerStorage.getStore(); + if (trigger !== undefined) return trigger; const surface = telemetryAppSurfaceStorage.getStore(); return surface === "web" || surface === "desktop" || surface === "mobile" ? "person" diff --git a/apps/server/test/services/plugins/plugin-authoring-docs.test.ts b/apps/server/test/services/plugins/plugin-authoring-docs.test.ts index fc31a8f77b0..71546b918fa 100644 --- a/apps/server/test/services/plugins/plugin-authoring-docs.test.ts +++ b/apps/server/test/services/plugins/plugin-authoring-docs.test.ts @@ -155,6 +155,7 @@ const PUBLIC_PLUGIN_SDK_EXPORT_NAMES = [ ); const BB_PLUGIN_API_KEYS = [ + "experimental_withFeatureUse", "pluginId", "log", "settings", diff --git a/apps/server/test/services/plugins/plugin-service.test.ts b/apps/server/test/services/plugins/plugin-service.test.ts index 053a3f5e7f3..325c921463b 100644 --- a/apps/server/test/services/plugins/plugin-service.test.ts +++ b/apps/server/test/services/plugins/plugin-service.test.ts @@ -873,13 +873,13 @@ describe("plugin service", () => { serverSource: `import { z } from "zod"; export default function plugin(bb) { bb.cli.register({ name: "private-work", description: "Work", run() { - bb.experimental_recordFeatureUse({ trigger: "schedule" }); + bb.experimental_withFeatureUse({ trigger: "schedule" }, () => undefined); return { exitCode: 0, stdout: "done" }; }}); bb.rpc.register({ read: { input: z.null(), output: z.null() }, act: { experimental_usage: "action", input: z.null(), output: z.null() } - }, { read: () => null, act: () => { bb.experimental_recordFeatureUse({ trigger: "schedule" }); return null; } }); + }, { read: () => null, act: () => { bb.experimental_withFeatureUse({ trigger: "schedule" }, () => undefined); return null; } }); }`, }); try { diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index 5d9a30aae00..21b0556e1c0 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -4214,5 +4214,5 @@ Stabilize after deciding whether depth, recency window, and limit should be call ## Anonymous capability capture - `PluginRpcMethodContract.experimental_usage`: optional `action` or `query` classification. Core registers the same metadata for bundled plugins and captures only explicit actions through the shared RPC boundary. Stabilize after plugin authors can classify actions without counting polling or restoration. -- `BbPluginApi.experimental_recordFeatureUse`: records one background work invocation using fixed trigger categories. Core owns source classification, nested-call suppression and opt-out. Automations uses it at actual agent/script execution, never at timer polling. Stabilize after execution and nested-call parity is verified. +- `BbPluginApi.experimental_withFeatureUse`: wraps one background work invocation using fixed trigger categories. Core owns source classification, nested-call suppression and opt-out. Automations uses it at actual agent/script execution, never at timer polling. Stabilize after execution and nested-call parity is verified. - `PluginsArea.experimental_recordPanelOpen`: core panel actions and route navigation use the same endpoint; the server resolves the installed plugin and sends only categorical provenance. Stabilize after panel restoration and explicit opens remain distinguishable. diff --git a/docs/configuration.md b/docs/configuration.md index 8ae161d56c4..bdbd41ec614 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2250,4 +2250,4 @@ new-thread and follow-up composers. Starred text and mentions persist in the plugin database. See the [Prompt Library skill](../plugins/prompt-library/skills/prompt-library/SKILL.md) for CLI and SDK commands. -Plugin capability usage passes through shared CLI, agent-tool, classified RPC action and explicit panel-open boundaries. Plugin RPC contracts may set `experimental_usage: "action"` or `"query"`; unclassified methods are excluded rather than guessed from names. `bb.experimental_recordFeatureUse({ trigger })` covers actual background work outside those boundaries, including scheduled automations. Polling, load, registration and restored panels do not count. Nested plugin RPC and core SDK calls are attributed to the outer plugin invocation. The server derives `builtin`, `public_catalog`, `private_marketplace`, `local` or `unknown` from installed provenance, including development overrides; the new event contains no plugin identifiers, names or URLs. +Plugin capability usage passes through shared CLI, agent-tool, classified RPC action and explicit panel-open boundaries. Plugin RPC contracts may set `experimental_usage: "action"` or `"query"`; unclassified methods are excluded rather than guessed from names. `bb.experimental_withFeatureUse({ trigger }, run)` covers actual background work outside those boundaries, including scheduled automations. Polling, load, registration and restored panels do not count. Nested plugin RPC and core SDK calls are attributed to the outer plugin invocation. The server derives `builtin`, `public_catalog`, `private_marketplace`, `local` or `unknown` from installed provenance, including development overrides; the new event contains no plugin identifiers, names or URLs. diff --git a/packages/plugin-sdk/src/__tests__/public-types.test.ts b/packages/plugin-sdk/src/__tests__/public-types.test.ts index 85b499699bf..b8e63065f94 100644 --- a/packages/plugin-sdk/src/__tests__/public-types.test.ts +++ b/packages/plugin-sdk/src/__tests__/public-types.test.ts @@ -16,7 +16,7 @@ import type { } from "../app-contract.js"; type ExpectedBbPluginApiKey = - | "experimental_recordFeatureUse" + | "experimental_withFeatureUse" | "agents" | "background" | "cli" diff --git a/packages/plugin-sdk/src/backend-contract.ts b/packages/plugin-sdk/src/backend-contract.ts index e87531b36ca..a0aa8d7b4e9 100644 --- a/packages/plugin-sdk/src/backend-contract.ts +++ b/packages/plugin-sdk/src/backend-contract.ts @@ -2089,10 +2089,13 @@ export type PluginBbSdk = Omit & { * against. */ export interface BbPluginApi { - /** Report one background work invocation; core resolves anonymous plugin provenance and applies opt-out. Do not report timers, polling, or startup. */ - experimental_recordFeatureUse(options: { - trigger: "person" | "agent" | "schedule" | "system" | "unknown"; - }): void; + /** Wrap one background work invocation; core resolves anonymous provenance, trigger context, nested calls and opt-out. Excludes timers, polling and startup. */ + experimental_withFeatureUse( + options: { + trigger: "person" | "agent" | "schedule" | "system" | "unknown"; + }, + run: () => T, + ): T; /** The plugin's own id (namespaces storage, routes, commands). */ readonly pluginId: string; /** Leveled, plugin-scoped logger. */ diff --git a/packages/plugin-sdk/src/testing/fake-plugin-host.ts b/packages/plugin-sdk/src/testing/fake-plugin-host.ts index 31d5dddae1d..91571ba5349 100644 --- a/packages/plugin-sdk/src/testing/fake-plugin-host.ts +++ b/packages/plugin-sdk/src/testing/fake-plugin-host.ts @@ -1499,7 +1499,9 @@ function createFakePluginHostInternal( }; const bb: BbPluginApi = { - experimental_recordFeatureUse() {}, + experimental_withFeatureUse(_options, run) { + return run(); + }, pluginId, log, settings, diff --git a/plugins/automations/src/automations.test.ts b/plugins/automations/src/automations.test.ts index a7b5f99080e..ff1448ed630 100644 --- a/plugins/automations/src/automations.test.ts +++ b/plugins/automations/src/automations.test.ts @@ -230,7 +230,8 @@ function createAutomationServiceBb() { }, }, }, - experimental_recordFeatureUse: () => undefined, + experimental_withFeatureUse: (_options: unknown, run: () => T): T => + run(), realtime: { publish: () => undefined }, log: { debug: () => undefined, @@ -365,7 +366,8 @@ describe("startup reconciliation", () => { }, }, }, - experimental_recordFeatureUse: () => undefined, + experimental_withFeatureUse: (_options: unknown, run: () => T): T => + run(), realtime: { publish: (...args: unknown[]) => void published.push(args), }, @@ -858,7 +860,8 @@ describe("automation data access", () => { }, }, }, - experimental_recordFeatureUse: () => undefined, + experimental_withFeatureUse: (_options: unknown, run: () => T): T => + run(), realtime: { publish: () => undefined }, log: { debug: () => undefined, @@ -1293,7 +1296,8 @@ describe("automation service", () => { }, }, }, - experimental_recordFeatureUse: () => undefined, + experimental_withFeatureUse: (_options: unknown, run: () => T): T => + run(), realtime: { publish: () => undefined }, log: { debug: () => undefined, @@ -2236,7 +2240,8 @@ describe("script project context", () => { }), }, }, - experimental_recordFeatureUse: () => undefined, + experimental_withFeatureUse: (_options: unknown, run: () => T): T => + run(), realtime: { publish: () => undefined }, log: { debug: () => undefined, diff --git a/plugins/automations/src/run.ts b/plugins/automations/src/run.ts index c7211cd265d..9f4a0aff8ae 100644 --- a/plugins/automations/src/run.ts +++ b/plugins/automations/src/run.ts @@ -35,13 +35,13 @@ type AgentThreadsSdk = { }; export type AgentRunApi = Pick< BbPluginApi, - "realtime" | "log" | "experimental_recordFeatureUse" + "realtime" | "log" | "experimental_withFeatureUse" > & { sdk: { threads: AgentThreadsSdk }; }; export type ScriptRunApi = Pick< BbPluginApi, - "realtime" | "log" | "experimental_recordFeatureUse" + "realtime" | "log" | "experimental_withFeatureUse" > & { sdk: { projects: ProjectsSdk }; }; @@ -115,50 +115,52 @@ export async function executeAgentRun( db: Db, args: AgentRunArgs, ): Promise { - bb.experimental_recordFeatureUse({ - trigger: args.run.trigger === "manual" ? "unknown" : "schedule", - }); - try { - if (args.automation.targetThreadId !== null) { - await reuseTargetThreadForRun(bb, db, { - ...args, - targetThreadId: args.automation.targetThreadId, - }); - return; - } - const thread = sdkThreadSchema.parse( - await bb.sdk.threads.spawn({ - projectId: args.automation.projectId, - environment: args.execution.environment, - prompt: renderAutomationDueMessage({ + return bb.experimental_withFeatureUse( + { trigger: args.run.trigger === "manual" ? "unknown" : "schedule" }, + async () => { + try { + if (args.automation.targetThreadId !== null) { + await reuseTargetThreadForRun(bb, db, { + ...args, + targetThreadId: args.automation.targetThreadId, + }); + return; + } + const thread = sdkThreadSchema.parse( + await bb.sdk.threads.spawn({ + projectId: args.automation.projectId, + environment: args.execution.environment, + prompt: renderAutomationDueMessage({ + automationId: args.automation.id, + prompt: args.execution.prompt, + }), + title: args.automation.name, + providerId: args.execution.providerId, + model: args.execution.model, + reasoningLevel: args.execution.reasoningLevel, + ...(args.execution.serviceTier === undefined + ? {} + : { serviceTier: args.execution.serviceTier }), + permissionMode: args.execution.permissionMode, + }), + ); + setAutomationRunThread(db, { runId: args.run.id, threadId: thread.id }); + markAutomationThread(db, { automationId: args.automation.id, - prompt: args.execution.prompt, - }), - title: args.automation.name, - providerId: args.execution.providerId, - model: args.execution.model, - reasoningLevel: args.execution.reasoningLevel, - ...(args.execution.serviceTier === undefined - ? {} - : { serviceTier: args.execution.serviceTier }), - permissionMode: args.execution.permissionMode, - }), - ); - setAutomationRunThread(db, { runId: args.run.id, threadId: thread.id }); - markAutomationThread(db, { - automationId: args.automation.id, - runId: args.run.id, - threadId: thread.id, - now: Date.now(), - }); - } catch (error) { - settleDispatchFailure(bb, db, args, error); - } finally { - publishAutomationChange(bb, args.automation.projectId, [ - "automations-changed", - "automation-runs-changed", - ]); - } + runId: args.run.id, + threadId: thread.id, + now: Date.now(), + }); + } catch (error) { + settleDispatchFailure(bb, db, args, error); + } finally { + publishAutomationChange(bb, args.automation.projectId, [ + "automations-changed", + "automation-runs-changed", + ]); + } + }, + ); } function settleDispatchFailure( @@ -278,62 +280,64 @@ export async function executeScriptRun( resolveWorkingDirectory: ScriptWorkingDirectoryResolver; }, ): Promise { - bb.experimental_recordFeatureUse({ - trigger: args.run.trigger === "manual" ? "unknown" : "schedule", - }); - try { - const scriptFile = args.execution.scriptFile; - if (scriptFile === undefined) { - closeAutomationRun(db, { - runId: args.run.id, - status: "failed", - error: "Script automation is missing a stored script file", - now: Date.now(), - }); - return; - } - const workingDir = await args.resolveWorkingDirectory( - args.automation.projectId, - args.execution.workingDirectory, - ); - if (workingDir === null) { - throw new Error( - `Project ${args.automation.projectId} has no source on the bb server host`, - ); - } - const result = await executeStoredScript({ - pluginDataDir: args.pluginDataDir, - automationId: args.automation.id, - runId: args.run.id, - projectId: args.automation.projectId, - scriptFile, - interpreter: args.execution.interpreter, - timeoutMs: args.execution.timeoutMs, - env: args.execution.env, - serverUrl: args.serverUrl, - workingDir, - }); - const mapped = mapScriptResultToRun(result); - closeAutomationRun(db, { - runId: args.run.id, - status: mapped.status, - skipReason: mapped.skipReason, - output: mapped.output, - exitCode: mapped.exitCode, - error: mapped.error, - now: Date.now(), - }); - } catch (error) { - args.onFailure(error); - bb.log.error( - `Failed to run script for automation ${args.automation.id}: ${errorMessage(error)}`, - ); - } finally { - publishAutomationChange(bb, args.automation.projectId, [ - "automations-changed", - "automation-runs-changed", - ]); - } + return bb.experimental_withFeatureUse( + { trigger: args.run.trigger === "manual" ? "unknown" : "schedule" }, + async () => { + try { + const scriptFile = args.execution.scriptFile; + if (scriptFile === undefined) { + closeAutomationRun(db, { + runId: args.run.id, + status: "failed", + error: "Script automation is missing a stored script file", + now: Date.now(), + }); + return; + } + const workingDir = await args.resolveWorkingDirectory( + args.automation.projectId, + args.execution.workingDirectory, + ); + if (workingDir === null) { + throw new Error( + `Project ${args.automation.projectId} has no source on the bb server host`, + ); + } + const result = await executeStoredScript({ + pluginDataDir: args.pluginDataDir, + automationId: args.automation.id, + runId: args.run.id, + projectId: args.automation.projectId, + scriptFile, + interpreter: args.execution.interpreter, + timeoutMs: args.execution.timeoutMs, + env: args.execution.env, + serverUrl: args.serverUrl, + workingDir, + }); + const mapped = mapScriptResultToRun(result); + closeAutomationRun(db, { + runId: args.run.id, + status: mapped.status, + skipReason: mapped.skipReason, + output: mapped.output, + exitCode: mapped.exitCode, + error: mapped.error, + now: Date.now(), + }); + } catch (error) { + args.onFailure(error); + bb.log.error( + `Failed to run script for automation ${args.automation.id}: ${errorMessage(error)}`, + ); + } finally { + publishAutomationChange(bb, args.automation.projectId, [ + "automations-changed", + "automation-runs-changed", + ]); + } + }, + ); } export function closeAutomationRunForSettledThread( diff --git a/plugins/automations/src/service.ts b/plugins/automations/src/service.ts index 4b2522dfb8a..8bcd8bfe65f 100644 --- a/plugins/automations/src/service.ts +++ b/plugins/automations/src/service.ts @@ -69,7 +69,7 @@ import { type ServiceApi = Pick< BbPluginApi, - "realtime" | "log" | "experimental_recordFeatureUse" + "realtime" | "log" | "experimental_withFeatureUse" > & { sdk: { system: { config(): Promise<{ primaryHostId: string | null }> }; diff --git a/plugins/plugin-api-docs/src/surfaces.ts b/plugins/plugin-api-docs/src/surfaces.ts index dbc5ccacbbf..d5ceb573524 100644 --- a/plugins/plugin-api-docs/src/surfaces.ts +++ b/plugins/plugin-api-docs/src/surfaces.ts @@ -754,6 +754,7 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ surfaceIds: [ "background", "wire", + "anonymous-usage", "thread-events", "dispatch-hook", "environment-providers", @@ -968,14 +969,14 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ tagline: "Categorical capability use with the existing opt-out", title: "Anonymous capability usage", summary: - "Describe explicit work without exporting plugin identities. With this, a plugin can:", + "Use categorical events for explicit work without exporting plugin identities. With this, a plugin can:", bullets: [ "Classify RPC methods as actions or queries; polling and unclassified methods do not count", - "Report background work with a fixed person, agent, schedule, system or unknown trigger", + "Wrap background work with a fixed person, agent, schedule, system or unknown trigger", "Use core's shared provenance classification, opt-out and nested-call suppression", ], apiSymbols: [ - "BbPluginApi.experimental_recordFeatureUse", + "BbPluginApi.experimental_withFeatureUse", "PluginBbSdk.plugins.experimental_recordPanelOpen", ], firstParty: ["Automations"], From 04e14fa3df87233c50266e335c5504e7d3e392f7 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:31:10 -0700 Subject: [PATCH 10/11] Fix plugin usage fixture dependencies and authoring index --- .../test/services/plugins/plugin-service.test.ts | 12 +++++++----- .../references/backend-api-index.md | 5 +++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/server/test/services/plugins/plugin-service.test.ts b/apps/server/test/services/plugins/plugin-service.test.ts index 325c921463b..daa96bbe465 100644 --- a/apps/server/test/services/plugins/plugin-service.test.ts +++ b/apps/server/test/services/plugins/plugin-service.test.ts @@ -870,20 +870,22 @@ describe("plugin service", () => { const tracked = createTelemetryTrackedService(captured); const rootDir = await writePlugin(workDir, { name: "bb-plugin-private-work", - serverSource: `import { z } from "zod"; - export default function plugin(bb) { + serverSource: `export default function plugin(bb) { + const schema = { "~standard": { version: 1, vendor: "fixture", validate: (value) => + value === null ? { value } : { issues: [{ message: "Expected null" }] } } }; bb.cli.register({ name: "private-work", description: "Work", run() { bb.experimental_withFeatureUse({ trigger: "schedule" }, () => undefined); return { exitCode: 0, stdout: "done" }; }}); bb.rpc.register({ - read: { input: z.null(), output: z.null() }, - act: { experimental_usage: "action", input: z.null(), output: z.null() } + read: { input: schema, output: schema }, + act: { experimental_usage: "action", input: schema, output: schema } }, { read: () => null, act: () => { bb.experimental_withFeatureUse({ trigger: "schedule" }, () => undefined); return null; } }); }`, }); try { - await tracked.installPath(rootDir); + const installed = await tracked.installPath(rootDir); + expect(installed.status, installed.statusDetail ?? undefined).toBe("running"); captured.length = 0; const read = tracked.getRpcHandler("private-work", "read"); const act = tracked.getRpcHandler("private-work", "act"); diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-api-index.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-api-index.md index b540e1b6c94..8636c280386 100644 --- a/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-api-index.md +++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-api-index.md @@ -15,6 +15,11 @@ Read the installed declarations for exact current signatures. - `BbContext` - `BbNavigate` - `BbPluginApi` +- `bb.experimental_withFeatureUse({ trigger }, run)` — wrap an actual background + operation in anonymous capability measurement. Trigger is `person`, `agent`, + `schedule`, `system`, or `unknown`. Nested operations count once; core derives + plugin source and respects the telemetry setting. See Plugin Guide's + "Anonymous usage" card for the shared RPC, tool, CLI, and panel boundaries. - `CodeOverflowMode` - `ComposerCustomization` - `ComposerPlusMenuItem` From 40972c5893b7dfdbbf427ea7b5673df9bd345c34 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:58:20 -0700 Subject: [PATCH 11/11] Use the plugin CLI summary field in the usage fixture --- apps/server/test/services/plugins/plugin-service.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/test/services/plugins/plugin-service.test.ts b/apps/server/test/services/plugins/plugin-service.test.ts index daa96bbe465..ec62cb88eec 100644 --- a/apps/server/test/services/plugins/plugin-service.test.ts +++ b/apps/server/test/services/plugins/plugin-service.test.ts @@ -873,7 +873,7 @@ describe("plugin service", () => { serverSource: `export default function plugin(bb) { const schema = { "~standard": { version: 1, vendor: "fixture", validate: (value) => value === null ? { value } : { issues: [{ message: "Expected null" }] } } }; - bb.cli.register({ name: "private-work", description: "Work", run() { + bb.cli.register({ name: "private-work", summary: "Work", run() { bb.experimental_withFeatureUse({ trigger: "schedule" }, () => undefined); return { exitCode: 0, stdout: "done" }; }});