Skip to content

Provider plugins: one API for Codex, Claude Code, pi, and ACP agents - #2325

Merged
SawyerHood merged 6 commits into
mainfrom
provider-plugins
Aug 24, 2026
Merged

Provider plugins: one API for Codex, Claude Code, pi, and ACP agents#2325
SawyerHood merged 6 commits into
mainfrom
provider-plugins

Conversation

@SawyerHood

@SawyerHood SawyerHood commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

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

What was wrong

Providers (Codex, Claude Code, pi, and every ACP agent) were wired into core with per-provider carve-outs: the host daemon carried a provider table and provider-specific skill/command scanning, the agent runtime carried provider-specific recovery regexes and session branches, the server and clients branched on provider ids, and third-party agents could only arrive through the ACP tier with a narrower API than first-party providers had. Adding or changing a provider meant touching core in many places, and the "plugin API" did not describe what the built-in providers actually used.

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 surface a third party uses.

- **Bridge protocol v3 + presentation**: items carry a declarative `presentation` (label, glyph, tint, detail); web renders rows from it and plugins may add React renderers for their own kinds; mobile renders the declarative base with no plugin JavaScript.
- **Registry**: `bb.providers.register` declares strings, icon, models, reasoning levels, service tiers, permission modes, native skill/command roots, maintenance (health, usage, installation), and AI services; one `ProviderInfo` shape everywhere.
- **Interactions**: closed approvals with `toolUse`, open plugin-defined requests (`<pluginId>/<name>` + `request_answer`), 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 every plugin — pi included — resolves host-only roots through `resolveNativeRoots` (the `absolute` side is gone); a bridge must return `providerThreadId` on `thread/start`, `thread/resume`, and `thread/fork` (the conformance kit pins the error); 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**: a plugin declares `bb.branding.experimental_icons` (name → SVG it ships); rows and provider branding reference `"<pluginId>/<name>"`; ingest rejects undeclared names; an uninstalled plugin falls back to the host glyph; declared-icon SVGs pass a strict validator and are served under `nosniff` + CSP. The pre-existing branding/logo routes now send the same headers; installed plugins' logos keep loading exactly as before, and `bb plugin build` checks logos for script vectors (script/handler/listener elements, `on*` attributes, `javascript:` hrefs) so authors see it when they build.
- **Stabilization**: `experimental_` dropped where audited (old keys rejected with messages naming the new ones; one-release aliases for app-runtime members); provider-literal ratchet 209 → 18 references, all allowlisted with reason/owner/dies-at.
- **Simplification (S7)**: dead adapter branches, a duplicate maintenance cache, duplicate validators/tests/fixtures removed; cross-plugin duplicates folded into `experimental_` SDK helpers (vendor plugin roots, presentation builders, the maintenance toolkit, bounded line reader, public-SDK-only scan), each with an `api_to_audit.md` entry; eleven already-published `provider-bridge` exports kept and listed under "Scheduled removals (next major)".
- **Wire**: `HOST_DAEMON_PROTOCOL_VERSION` 153 → 164 (entries 154–164 in the header). `@get-bb/plugin-sdk` → 0.4.16 (main ships 0.4.15), bumped inside the first layer that changes the published surface so that layer passes the per-PR SDK guard on its own.
- **Packaging**: `bb plugin build` now inlines the SDK subpaths a plugin server imports (`@get-bb/plugin-sdk/host`, `/provider-bridge/acp`) and leaves only the bare specifier external; before this, a packaged install loaded none of the provider plugins (the packed server aliases only the bare specifier). `smoke:tarball` is green with pi absent from PATH.
- **Kept on purpose**: `experimental_setThreadRowStatus` and its status store stay (main's collapsed-section plugin-status rollup consumes them); the audit doc names the consumer.
- **Docs**: `docs/provider-plugin-api.md` (design), `docs/api_to_audit.md` (experimental surface, windows, scheduled removals, deleted members), the plugin-authoring skill, the bb guide, CLI surfaces.

Base: the current `main` (the private mirror was brought level with the public tip first). The branch is strictly linear and rebased; every conflict with main's recent changes (shared vitest workers, parallel SDK d.ts bundling, the collapsed-section status rollup, the mobile e2e flow) was resolved three-way and re-verified. The work was built as 151 reviewed layers and squashed into this commit; the layered history is kept on the private repository.

How it was verified

- Typecheck 76/76 packages; full test sweep green on the rebased tip; CI's build + lint, the package smoke (`smoke:tarball`), the plugin-SDK version guard against the merge-base, the npm version guard, and the version-lockstep check.
- Parity harness: 39 recorded bridge cells (codex 16, claude-code 13, acp-cursor 10) replayed through the pre-migration main and the chain with zero diffs outside recordings/parity-allowlist.json (each entry named and reasoned); pi via re-recorded cells and the recorded-conformance kit.
- Provider corpus: 340 real threads (307 production codex/claude-code threads + 32 pi threads captured on a dev instance of main, baselines main-minted), 94,365 rows, every changed row in a named, reviewed class, 0 unclassified — re-run at every stack.
- Permission matrix: 134 pinned cells identical; native-roots goldens 22/22 (two new pi variants); every bridge's conformance kit green; the real-provider integration suite (live Codex, Claude Code, pi 0.84, ACP agents) 9 files / 64 tests.
- Adversarial refutation passes per layer and on every stacked result (independent read-only reviewers probing behaviour, coverage, security, and public-push readiness); every removal in the simplification series was independently refuted before it was stacked.
- Live QA in the dev app: Codex, Claude Code, Cursor and grok (ACP), the echo example plugin, and pi each ran a tool turn.

Adversarial review of the squashed change: 27 confirmed findings fixed (29 reports with two duplicate pairs folded; one of them was the parity wording corrected above), plus three cheap hardenings the review recommended. Clients: a new client keeps exploration titles for the tool rows a pre-161 server serves; an embedded thread chat scopes plugin renderers to its own provider; a deferred provider plugin's bundle loads for its forms and its panel route; a tint React Native cannot paint falls back instead of painting black, a blank extension detail stays title-only, and images in a presentation detail render as alt text on every surface. Runtime: the retry ladder and the post-unarchive retry act on the typed recovery hint of every attempt (no other kind can become a `rate_limited` error), and a `restartRecommended` restart is scoped to the threads and the process it may touch. Bridges: the ACP kit types `authRequired` only from the agent's own auth error, settles the turn when an interrupt times out, reaps a session released during construction, and defers updates that arrive before the identity announcement; a codex turn whose rebuild hits an archived rollout stays resumable. Server and contracts: the presentation-glyph ingest check runs on every item-bearing event, the delegation and backgroundTask snapshots included; the 0.4.15 provider-bridge exports whose definitions moved are restored as aliases under "Scheduled removals", every removed or renamed 0.4.15 export is named in the audit doc, a pi child's scratch files go with the child, and the presentation-less tool-row window is documented as pi uses it. Tests and oracles: the conformance kit pins the resume and fork identities, the fake plugin host refuses the registrations the server refuses, the public-SDK scan reports relative escapes and dynamic specifiers, the corpus row-class gate judges idle classes per entry, lets a reshaped class name its fields and no longer claims a turn status change under container-bounds, and the native-roots goldens keep skill descriptions. Stacking the fixes surfaced one more, fixed here as well: the web search-reveal timers outlived the timeline rows they targeted, which surfaced as an unhandled error in the web test suite.

A second pass took the remaining items: extension kinds are accepted only from their owning plugin's threads, so an extension item or state record whose kind belongs to another plugin is refused at ingest and surfaces as the same visible unhandled row a schema miss does. A childless delegation call in persisted history (an `Agent`, `Task`, or `spawnAgent` call whose subagent never produced a child event) classifies as a delegation row keyed by its call id, the row it had before, instead of a plain tool row; new events keep classifying structurally, the bridge's `delegation` item naming the call id as its `childRef`. A plugin whose host artifact fails to build keeps its provider listed as unavailable, with the build error as the reason, even when the same plugin also registers an AI service (before, such a plugin vanished from the provider list). The server alone authors interaction lifecycle records: the daemon-posted ones are dropped at the internal event-append route instead of being kept for known interaction ids, and the host-daemon protocol moves to 164 to record the narrowed acceptance.

Older mobile builds are not supported: the app is on TestFlight and ships with the server, so the timeline wire serves grammar v3 only — no client capability header, no legacy projection, and no `activityIntents` key on tool rows; the readers that existed for those rows are gone with it; the `ProviderInfo` maintenance aliases (`experimental_providerHealth`/`experimental_providerUsage`/`experimental_providerInstallation`) kept for clients built before protocol 161 are gone as well — read `maintenance`.

Co-Authored-By: Claude <noreply@anthropic.com>
SawyerHood and others added 5 commits August 24, 2026 03:48
… yet

The tarball smoke polls `bb plugin list` until every expected builtin is
running, but threw on the first plugin whose status was "error". The
server reports an enabled plugin that `loadAll` has not reached yet with
exactly that status and the detail "not loaded" (it has no runtime status
at all), and plugins load one at a time after the HTTP server starts
listening. On a cold CI runner the poll landed mid-load and the six
builtins still queued behind provider-acp failed the smoke.

The smoke now treats that explicit no-status-yet detail as pending and
fails only on a plugin whose load actually failed.

Verified: smoke:tarball on Node 22 and Node 24 with pi and opencode2
absent from PATH.

Co-Authored-By: Claude <noreply@anthropic.com>
replayRecording gated each runtime request on the events the recording had
before it, but gave up after orderTimeoutMs of bridge silence and sent the
request anyway. With a plan from the current lane that shortfall can only
be latency, since the same bridge wrote the lane; on a starved CI runner
the bridge went quiet with provider lines still unread, the steer then
arrived on the bridge's stdin while those lines were pending, and the
bridge drained the two streams in an order the recording never had (in
codex/steer every row from the fourth command on shifted).

An exact plan now waits for its events up to timeoutMs and reports a stall
if they never come. The quiet fallback stays for plans from the recorded
lane, where a divergent bridge legitimately emits fewer events.

Verified: @bb/provider-parity 56/56 twice; codex/steer no slower.

Co-Authored-By: Claude <noreply@anthropic.com>
…light

An unsolicited restartRecommended hint that arrived while one of the
thread's operations was in flight was kept but not scheduled until the
thread's next turn. One read can carry the turn/start response, the turn's
terminal delta and the hint together: the response settles the turn
operation on a microtask that runs only after the whole batch, so the hint
found the operation in flight, and a thread that was idle a moment later
restarted only at its next turn. The recovery test that expects the
sibling-busy deferral to be logged right after the turn failed one run in
three under load.

The runtime now retries such a hint as soon as the thread's operations
drain. A hint carried by a rejected request keeps restarting before the
next turn, as before. The scripted echo bridge gains
turnStartResponseDelayMs so a test can raise the hint before the turn/start
answer; the new test fails without the fix and passes with it.

Verified: @bb/agent-runtime 316/316 solo and three times under CPU and
suite load; typecheck across packages.

Co-Authored-By: Claude <noreply@anthropic.com>
The test that pins the 256-root cap wrote 300 plugins one after another
(two filesystem writes each) and then resolved them; on a slow CI runner
that ran past the 5 s default test timeout. 260 plugins exceed the cap by
the same mechanism and prove the same cut-off and warning, and the fixture
writes are now issued together instead of one at a time: the file runs in
about 150 ms.

Co-Authored-By: Claude <noreply@anthropic.com>
Four pi bridge tests failed on a starved CI runner for three reasons, none
of them the bridge misbehaving.

The test harness called a request unanswered after 20 s, while the bridge
was still inside its own budget: a construction it retries spawns up to
eight node children through the transient-auth window, several seconds
each on a loaded runner, against a readiness budget of a minute per
attempt. The harness now waits 60 s, and the lifecycle and round2 tests
that drive such constructions get 90 s each.

The test that pins "a child whose extension never reports ready" shortens
the readiness budget to 1.5 s, but that budget also capped the child's
first get_state, so a slow spawn produced "did not answer get_state in
time" instead. The first command now waits on the channel budget like any
other and the readiness budget starts once pi has answered, so it bounds
the extension's report alone; the default budgets are unchanged.

The child-exit check waited 10 s for a close that escalates through a 4 s
SIGTERM grace and a 4 s SIGKILL grace before the exit reaches the process
log; it now waits 30 s.

Verified: bb-plugin-provider-pi 104/104 solo and three times under CPU
and suite load (the lifecycle file took up to 48 s there); typecheck.

Co-Authored-By: Claude <noreply@anthropic.com>
@SawyerHood
SawyerHood merged commit e42a4ef into main Aug 24, 2026
15 checks passed
@SawyerHood
SawyerHood deleted the provider-plugins branch August 24, 2026 04:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant