Skip to content

Commit e42a4ef

Browse files
SawyerHoodclaude
andauthored
Provider plugins: one API for Codex, Claude Code, pi, and ACP agents (#2325)
## What was wrong Providers (Codex, Claude Code, pi, and every ACP agent) were wired into core with per-provider carve-outs: a provider table and provider-specific skill/command scanning in the daemon, provider-specific recovery regexes and session branches in the runtime, provider-id branches in the server and clients, and a narrower ACP-only path for third-party agents. Adding or changing a provider meant touching core in many places. ## What changed Providers are plugins behind one API with zero first-party privilege: the built-in providers use the same `bb.providers.register` declaration, bridge protocol, and SDK a third party uses. - Bridge protocol v3: items carry a declarative `presentation` (label, glyph, tint, detail); web renders from it, plugins may add React renderers for their own kinds, mobile renders the declarative base. - `bb.providers.register` declares strings, icon, models, reasoning levels, service tiers, permission modes, native skill/command roots, maintenance, and AI services; one `ProviderInfo` shape everywhere. - Interactions: closed approvals with `toolUse`, open plugin-defined requests, one lifecycle event; the server stays the policy owner. - Runtime and daemon: typed recovery hints replace regexes; one bridge process per provider artifact with per-thread lanes; the daemon scans declared roots only and resolves host-only roots through `resolveNativeRoots`; the ACP tier is deleted and the ACP plugin owns its agents through a published ACP bridge kit. - pi moves from a built-in runtime to a plugin over `pi --mode rpc` with an install gate. - Plugin icons: `bb.branding.experimental_icons` (name → SVG), namespaced glyphs, strict SVG validation, `nosniff` + CSP; `bb plugin build` checks logos for script vectors. - Stabilization: `experimental_` dropped where audited; provider-literal ratchet 209 → 18 allowlisted references; cross-plugin duplicates folded into `experimental_` SDK helpers; eleven published `provider-bridge` exports kept under "Scheduled removals". - Wire: `HOST_DAEMON_PROTOCOL_VERSION` 153 → 164; `@get-bb/plugin-sdk` 0.4.16; `bb plugin build` inlines SDK subpaths so packaged installs load provider plugins. - Older mobile builds are not supported (the app ships with the server): the timeline wire serves grammar v3 only — no capability header, no `activityIntents` on tool rows, no `ProviderInfo` maintenance aliases. - Docs: `docs/provider-plugin-api.md`, `docs/api_to_audit.md`, the plugin-authoring skill, the bb guide, CLI surfaces. Built as reviewed layers and squashed into one commit, then adversarially reviewed (51 findings; every confirmed one fixed with a failing-first test and an independent re-check — the commit message lists them). ## How you verified - Typecheck 76/76; full test sweep; CI build + lint; `smoke:tarball`; SDK version guards. - Parity harness: 39 recorded bridge cells replayed through the pre-migration main and this branch, zero diffs outside the named allowlist. - Provider corpus: 340 real threads / 94,365 rows, every changed row in a named class, 0 unclassified. - Permission matrix 134 cells identical; native-roots goldens 22/22; every bridge's conformance kit green; real-provider integration suite (Codex, Claude Code, pi, ACP agents) 64 tests. - Live QA: Codex, Claude Code, Cursor and grok (ACP), the echo example plugin, and pi each ran a tool turn. > AGENT GENERATED: by Claude Opus 5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5205d98 commit e42a4ef

884 files changed

Lines changed: 77701 additions & 30181 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/app/.ladle/model-picker-query-provider.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,11 @@ const STORY_COMPOSER_ACTIONS_BY_PROVIDER: Record<
5151
const STORY_PROVIDER_INFOS: ProviderInfo[] = STORY_PROVIDER_OPTIONS.map(
5252
(provider) => ({
5353
id: provider.value,
54+
pluginId: `provider-${provider.value}`,
5455
displayName: provider.label,
5556
logoUrl: null,
5657
available: true,
57-
experimental_providerHealth: true,
58-
experimental_providerUsage: true,
59-
experimental_providerInstallation: true,
58+
maintenance: { health: true, usage: true, installation: true },
6059
composerActions: [
6160
...(STORY_COMPOSER_ACTIONS_BY_PROVIDER[provider.value] ?? []),
6261
],
@@ -67,6 +66,7 @@ const STORY_PROVIDER_INFOS: ProviderInfo[] = STORY_PROVIDER_OPTIONS.map(
6766
supportsNativeUserQuestion: true,
6867
supportsFork: true,
6968
supportsSessionRewind: true,
69+
modelCatalogScope: "workspace",
7070
permissionModes: [...permissionModes],
7171
},
7272
}),

apps/app/.ladle/settings-story-fixtures.tsx

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,10 @@
11
import { useState, type ReactNode } from "react";
22
import { useNavigate } from "react-router-dom";
33
import { QueryClientProvider } from "@tanstack/react-query";
4-
import {
5-
PERSONAL_PROJECT_ID,
6-
defaultAppSettings,
7-
defaultAppTheme,
8-
defaultExperiments,
9-
} from "@bb/domain";
4+
import { PERSONAL_PROJECT_ID } from "@bb/domain";
105
import { UPDATE_ACTION_ICON } from "@bb/domain/update-state";
116
import type {
127
SidebarBootstrapResponse,
13-
SystemConfigResponse,
148
SystemVersionResponse,
159
} from "@bb/server-contract";
1610
import type { ProviderCliStatusResponse } from "@bb/host-daemon-contract";
@@ -28,6 +22,7 @@ import {
2822
type UpdateInventoryMachine,
2923
} from "../src/hooks/useUpdateInventory";
3024
import { createAppQueryClient } from "../src/lib/query-client";
25+
import { makeSystemConfig } from "../src/test/fixtures/system-config";
3126
import { getSettingsRoutePath } from "../src/lib/route-paths";
3227
import {
3328
BbAppUpdateRows,
@@ -129,24 +124,12 @@ const sidebarNavigation = {
129124
},
130125
} satisfies SidebarBootstrapResponse;
131126

132-
const systemConfig = {
133-
generalSettings: defaultAppSettings,
134-
keybindings: [],
135-
defaultKeybindings: [],
136-
keybindingOverrides: [],
137-
experiments: defaultExperiments,
138-
appearance: defaultAppTheme,
139-
customThemes: [],
140-
pluginThemes: [],
141-
featureFlags: { placeholder: false, timelineWindowEventBudget: 1_500 },
142-
hostDaemonPort: null,
143-
localHelperPorts: [],
144-
serverUrl: "http://localhost:38886",
127+
const systemConfig = makeSystemConfig({
145128
primaryHostId: HOST_IDS.local,
146129
primaryHostPlatform: "darwin",
147130
voiceTranscriptionEnabled: true,
148131
dataDir: "/Users/michael/.bb",
149-
} satisfies SystemConfigResponse;
132+
});
150133

151134
const systemVersion = {
152135
currentVersion: "0.39.0",

apps/app/.ladle/story-fixtures.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,7 @@ import type {
1212
ProviderCliStatus,
1313
} from "@bb/host-daemon-contract";
1414
import type { ProjectResponse } from "@bb/server-contract";
15-
import { ClaudeIcon } from "../src/components/icons/ClaudeIcon";
16-
import { OpenAiIcon } from "../src/components/icons/OpenAiIcon";
17-
import { PiIcon } from "../src/components/icons/PiIcon";
15+
import { getProviderIconInfo } from "../src/lib/provider-icon";
1816
import type { PickerOption } from "../src/components/pickers/OptionPicker";
1917
import type { ModelPickerOption } from "../src/components/pickers/model-picker-option";
2018
import type { ProjectSelectorOption } from "../src/components/pickers/ProjectSelector";
@@ -114,10 +112,21 @@ export function makeAttachmentsConfig(
114112
// need to pre-format.
115113
// ---------------------------------------------------------------------------
116114

115+
// Core vendors no brand marks (they come from the provider plugins' declared
116+
// logos), so stories draw each provider through a declared host glyph.
117+
function storyProviderIcon(providerId: string, glyph: string) {
118+
return getProviderIconInfo(providerId, { logoUrl: null, icon: { glyph } })
119+
?.icon;
120+
}
121+
117122
export const STORY_PROVIDER_OPTIONS: readonly PickerOption<string>[] = [
118-
{ value: "codex", label: "Codex", icon: OpenAiIcon },
119-
{ value: "claude-code", label: "Claude Code", icon: ClaudeIcon },
120-
{ value: "pi", label: "Pi", icon: PiIcon },
123+
{ value: "codex", label: "Codex", icon: storyProviderIcon("codex", "Code") },
124+
{
125+
value: "claude-code",
126+
label: "Claude Code",
127+
icon: storyProviderIcon("claude-code", "Sparkles"),
128+
},
129+
{ value: "pi", label: "Pi", icon: storyProviderIcon("pi", "Zap") },
121130
];
122131

123132
export const STORY_CODEX_MODELS: readonly PickerOption<string>[] = [

apps/app/src/components/code/DiffHost.test.tsx

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
setPluginSlotRegistrations,
1515
} from "@/lib/plugin-slots";
1616
import { resetAllCrashedPluginSlotsForTest } from "@/components/plugin/PluginSlotMount";
17+
import { resetDeprecatedAliasWarningsForTests } from "@/lib/plugin-sdk-deprecated-aliases";
1718
import { parseGitDiffFiles } from "@/components/git-diff/git-diff-parsing";
1819
import { PluginDiff } from "@/components/plugin/PluginDiff";
1920
import {
@@ -112,6 +113,7 @@ beforeEach(() => {
112113
bbDiff.lastProps = null;
113114
receivedProps.length = 0;
114115
resetPluginSlotStoreForTest();
116+
resetDeprecatedAliasWarningsForTests();
115117
applyResolvedCodeTheme(defaultResolvedCodeTheme);
116118
});
117119

@@ -203,7 +205,7 @@ describe("DiffHost", () => {
203205
});
204206

205207
it("loads BB's renderer only when the replacement delegates", async () => {
206-
registerDiffRenderer(({ path, experimental_Original: Original }) =>
208+
registerDiffRenderer(({ path, Original }) =>
207209
path.endsWith(".ts") ? <Original /> : <div>plugin diff</div>,
208210
);
209211

@@ -378,3 +380,63 @@ describe("experimental_Diff", () => {
378380
expect(bbDiff.loaded).toBe(false);
379381
});
380382
});
383+
384+
/**
385+
* A bundle built against an SDK before 0.4.16 reads `experimental_Original`
386+
* (renamed `Original` in 0.4.16). The host passes both for one release.
387+
*/
388+
describe("DiffHost experimental_Original alias", () => {
389+
it("delegates to BB's renderer through the alias and warns once across renders", async () => {
390+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
391+
let renders = 0;
392+
registerDiffRenderer(({ experimental_Original: LegacyOriginal }) => {
393+
renders += 1;
394+
return LegacyOriginal === undefined ? (
395+
<div>alias missing</div>
396+
) : (
397+
<LegacyOriginal />
398+
);
399+
});
400+
401+
const { rerender } = render(
402+
<DiffHost
403+
file={parseFixture()}
404+
patchText={PATCH}
405+
fullFileContents={null}
406+
/>,
407+
);
408+
expect(await screen.findByTestId("bb-diff")).toBeDefined();
409+
expect(bbDiff.lastProps?.view).toBe("unified");
410+
411+
rerender(
412+
<DiffHost
413+
file={parseFixture()}
414+
patchText={PATCH}
415+
fullFileContents={null}
416+
view="split"
417+
/>,
418+
);
419+
expect(await screen.findByText("bb diff split/scroll")).toBeDefined();
420+
expect(renders).toBe(2);
421+
expect(warn).toHaveBeenCalledTimes(1);
422+
expect(warn).toHaveBeenCalledWith(
423+
"experimental_Original is deprecated; use Original. Removed in bb 0.42",
424+
);
425+
});
426+
427+
it("never warns for a renderer that reads Original", async () => {
428+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
429+
registerDiffRenderer(({ Original }) => <Original />);
430+
431+
render(
432+
<DiffHost
433+
file={parseFixture()}
434+
patchText={PATCH}
435+
fullFileContents={null}
436+
/>,
437+
);
438+
439+
expect(await screen.findByTestId("bb-diff")).toBeDefined();
440+
expect(warn).not.toHaveBeenCalled();
441+
});
442+
});

apps/app/src/components/code/DiffHost.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Suspense, lazy, useMemo, type ReactNode } from "react";
22
import type { ExperimentalDiffFullFileContents } from "@get-bb/plugin-sdk";
33
import { PluginReplacementSlot } from "@/components/plugin/PluginReplacementSlot";
4+
import { deprecatedOriginalAlias } from "@/lib/plugin-sdk-deprecated-aliases";
45
import type { ParsedGitDiffFile } from "@/components/git-diff/git-diff-parsing";
56
import { buildFileDiffPatchText } from "@/components/git-diff/git-diff-patch-text";
67
import { useDiffRendererReplacement } from "./codeRendererProvider";
@@ -47,7 +48,7 @@ interface DiffHostProps extends Partial<DiffPresentation> {
4748
* actually mounts.
4849
*
4950
* BB's own renderer sits behind `lazy()`. A plugin replacement that never
50-
* delegates therefore never downloads it, and `experimental_Original` costs
51+
* delegates therefore never downloads it, and `Original` costs
5152
* nothing until it is actually rendered.
5253
*/
5354
export function DiffHost({
@@ -100,7 +101,8 @@ export function DiffHost({
100101
overflow={overflow}
101102
showLineNumbers={showLineNumbers}
102103
experimental_fullFileContents={fullFileContents}
103-
experimental_Original={BoundOriginal}
104+
Original={BoundOriginal}
105+
experimental_Original={deprecatedOriginalAlias(BoundOriginal)}
104106
/>
105107
</div>
106108
)}

apps/app/src/components/code/SourceCodeHost.test.tsx

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
setPluginSlotRegistrations,
99
} from "@/lib/plugin-slots";
1010
import { resetAllCrashedPluginSlotsForTest } from "@/components/plugin/PluginSlotMount";
11+
import { resetDeprecatedAliasWarningsForTests } from "@/lib/plugin-sdk-deprecated-aliases";
1112
import { PluginSourceCode } from "@/components/plugin/PluginSourceCode";
1213
import { SourceCodeHost } from "./SourceCodeHost";
1314

@@ -54,6 +55,7 @@ beforeEach(() => {
5455
bbSourceCode.lastProps = null;
5556
received.length = 0;
5657
resetPluginSlotStoreForTest();
58+
resetDeprecatedAliasWarningsForTests();
5759
});
5860

5961
afterEach(() => {
@@ -109,7 +111,7 @@ describe("SourceCodeHost", () => {
109111
});
110112

111113
it("loads BB's renderer only when the replacement delegates", async () => {
112-
registerSourceCodeRenderer(({ path, experimental_Original: Original }) =>
114+
registerSourceCodeRenderer(({ path, Original }) =>
113115
path.endsWith(".md") ? <div>plugin source</div> : <Original />,
114116
);
115117

@@ -165,3 +167,51 @@ describe("experimental_SourceCode", () => {
165167
expect(bbSourceCode.loaded).toBe(false);
166168
});
167169
});
170+
171+
/**
172+
* A bundle built against an SDK before 0.4.16 reads `experimental_Original`
173+
* (renamed `Original` in 0.4.16). The host passes both for one release.
174+
*/
175+
describe("SourceCodeHost experimental_Original alias", () => {
176+
it("delegates to BB's renderer through the alias and warns once across renders", async () => {
177+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
178+
let renders = 0;
179+
registerSourceCodeRenderer(({ experimental_Original: LegacyOriginal }) => {
180+
renders += 1;
181+
return LegacyOriginal === undefined ? (
182+
<div>alias missing</div>
183+
) : (
184+
<LegacyOriginal />
185+
);
186+
});
187+
188+
const { rerender } = render(
189+
<SourceCodeHost content={CONTENT} path="src/app.ts" />,
190+
);
191+
expect(await screen.findByTestId("bb-source-code")).toBeDefined();
192+
expect(bbSourceCode.lastProps?.overflow).toBe("scroll");
193+
194+
rerender(
195+
<SourceCodeHost content={CONTENT} path="src/app.ts" overflow="wrap" />,
196+
);
197+
await act(async () => {
198+
await Promise.resolve();
199+
});
200+
expect(bbSourceCode.lastProps?.overflow).toBe("wrap");
201+
expect(renders).toBe(2);
202+
expect(warn).toHaveBeenCalledTimes(1);
203+
expect(warn).toHaveBeenCalledWith(
204+
"experimental_Original is deprecated; use Original. Removed in bb 0.42",
205+
);
206+
});
207+
208+
it("never warns for a renderer that reads Original", async () => {
209+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
210+
registerSourceCodeRenderer(({ Original }) => <Original />);
211+
212+
render(<SourceCodeHost content={CONTENT} path="src/app.ts" />);
213+
214+
expect(await screen.findByTestId("bb-source-code")).toBeDefined();
215+
expect(warn).not.toHaveBeenCalled();
216+
});
217+
});

apps/app/src/components/code/SourceCodeHost.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Suspense, lazy, type ReactNode } from "react";
22
import { PluginReplacementSlot } from "@/components/plugin/PluginReplacementSlot";
3+
import { deprecatedOriginalAlias } from "@/lib/plugin-sdk-deprecated-aliases";
34
import { useSourceCodeRendererReplacement } from "./codeRendererProvider";
45
import {
56
DEFAULT_CODE_OVERFLOW,
@@ -71,7 +72,8 @@ export function SourceCodeHost({
7172
path={path}
7273
overflow={overflow}
7374
highlightedLines={highlightedLines}
74-
experimental_Original={BoundOriginal}
75+
Original={BoundOriginal}
76+
experimental_Original={deprecatedOriginalAlias(BoundOriginal)}
7577
/>
7678
</div>
7779
)}

apps/app/src/components/dialogs/AddMachineDialog.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ function connectPlugin(
7777
app: { hasApp: false, bundle: null },
7878
logoUrl: null,
7979
logoDarkUrl: null,
80+
providerIds: [],
81+
icons: {},
8082
...overrides,
8183
};
8284
}

apps/app/src/components/icons/ClaudeIcon.tsx

Lines changed: 0 additions & 13 deletions
This file was deleted.

apps/app/src/components/icons/CursorIcon.tsx

Lines changed: 0 additions & 14 deletions
This file was deleted.

0 commit comments

Comments
 (0)