Skip to content

Agent providers as a first-class plugin surface (provider bridge protocol) - #1640

Open
ymichael wants to merge 148 commits into
mainfrom
bb/plan-agent-provider-changes-thr_gcuc46ug4j
Open

Agent providers as a first-class plugin surface (provider bridge protocol)#1640
ymichael wants to merge 148 commits into
mainfrom
bb/plan-agent-provider-changes-thr_gcuc46ug4j

Conversation

@ymichael

@ymichael ymichael commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Agent providers as a first-class plugin surface

Implements plans/agent-provider-plugin-surface.md end to end — phases 1–6 plus graduation. Providers declare their metadata through the plugin API and run as plugin-registered provider bridges speaking one bb-owned, provider-agnostic JSON-RPC protocol. The canonical path is no longer gated: the experiment, the legacy adapters, and the core provider catalog are all deleted, so the bridge protocol is the only way a provider reaches bb.

132 commits, 622 files, +41,035 / −25,028 (git diff --stat origin/main..HEAD, rename-aware — most of the insertions are provider sources moving into plugins/provider-*).

What's here, by phase (commits are ordered to match)

  1. Protocol + kit@bb/provider-bridge-protocol: versioned handshake with session-behavior capabilities, canonical requests/notifications (including end-to-end skills/configure), error codes, an event-grammar doc, and a transport-agnostic conformance kit (12 rules, incl. full streaming-delta coverage and zero-work-prompt settlement).
  2. Four conformant bridges — acp, pi, claude-code rewritten onto the canonical dialect (translation extracted byte-identically into shared modules both paths import); codex is the one new bridge, owning per-thread codex app-server children with the Fix provider process exit lifecycle races #1402 supervision rules. One generic BridgeProviderAdapter replaces per-provider adapter glue. Turn-start watchdog (provider_turn_start_timeout).
  3. Server registry + SDKProviderRegistryService (catalog-equality pinned), bb.agents.experimental_registerProvider, and app.slots.experimental_providerIcon (plugins ship theme-aware React brand marks; logoUrl files remain for static color logos). Both have docs/api_to_audit.md entries.
  4. First-party pluginsplugins/provider-{codex,claude-code,pi,acp} take over their core-seed entries in place (position preserved, restored on disable, takeover merge preserves flagship behaviors) and register their brand marks as app components. The app no longer depends on @bb/agent-providers.
  5. Third-party artifact deliverybb.providerBridge manifest key builds a self-contained bridge bundle; server stores/serves it content-addressed; daemon caches by verified hash; a verified bridgeLaunch (carrying the declaration's validated execution capabilities) is its own routing authority. HOST_DAEMON_PROTOCOL_VERSION 123 → 124 (the PR's only bump; main took 123 for artifact engine ranges). examples/plugins/echo-provider proves the path end to end.

Graduation (waves 1–5)

Each wave ended with a full board (typecheck + all suites) before the next started, and every deletion first scanned the dying adapter's tests for uncovered shared-module invariants and moved them.

  1. The pinned gap, then acp + pi — the codex zero-work-prompt settlement fix landed first (dispatch-ownership seam, no fabricated turns), unpinning the last non-green conformance rule; acp's and pi's legacy adapters were deleted.

  2. claude-code, then codex — the last two legacy adapters deleted. No legacy adapter remains anywhere.

  3. Flag and core-catalog retirement — the providerBridge experiment, the whole provider-bridge-policy endpoint, and the daemon's prefix capture are gone; the registry's core seed is deleted and @bb/agent-providers no longer exists. The runtime's codex special cases were audited one by one and consciously kept (one stale comment fixed). v124 was unshipped, so no further bump was needed.

  4. Phase-6 consolidation sweep — one commit per item, each red-verified: ACP manual compaction as a per-agent declaration; the skill provider id opened and the six per-provider skill scopes collapsed to provider-user/provider-project; ask-user-question reading supportsNativeUserQuestion; edit-message eligibility reading the declared supportsNativeSessionRewind; plan mode gating on the declared plan composer action (plus deleting the thread-view display-name switch); and the thread/openWork notification that finally gives hasOpenThreadWork a canonical implementation and stops the reaper killing codex native subagents.

  5. First-party artifact migration — two moves. First the bridge kit: a plugin-shipped bridge cannot import @bb/agent-runtime, and nearly every module under agent-runtime/src/shared/ turned out to have only provider-side consumers, so they moved wholesale (with their tests) to @bb/provider-bridge-protocol/bridge-kit — JSON-RPC plumbing, the stdio harness, tool-call/interaction codecs, id scoping, visibility metadata, translation helpers, and the runtime↔bridge structural types; bridge test infrastructure became @bb/provider-bridge-protocol/testing. Then the bridges themselves: codex (868 KB), acp (919 KB) and claude-code (2.44 MB) now ship as self-contained, content-addressed plugin artifacts on the same route as echo-provider, with every core carve-out removed end to end (daemon bundle target, bb-app files entries, launcher assertions, registry branches, catalog capability/session-restore baselines) and the tarball smoke driving the packed artifacts.

    Pi is the one documented exception, and the evidence is in the plan: a fully-inlined 14.8 MB artifact was built and driven through the real fixtures plus this machine's live pi config. It fails for three reasons, none of them module identity (an identity-divergent control run passed everything): pi-coding-agent's extension loader resolves itself by __dirname with no fallback, import.meta.resolve for the pi packages has no node_modules ancestor in the daemon cache, and pi-ai's OAuth loader is deliberately bundler-hostile. The only shape proven to work is a directory artifact (bridge + a real npm tree), which is future design work. Pi's bridge therefore stays daemon-bundled — the only bundled bridge left — while its daemon coupling was still removed (createConfiguredPiSettingsManager inlined, the @bb/agent-runtime re-export deleted).

After wave 5 every provider-specific line lives in plugins/provider-*; @bb/agent-runtime keeps only the protocol, the generic adapter, and supervision. The deliberate exceptions still id-switched in core are listed in the plan (skill-root union normalizers, isAcpProviderId, the acp-cursor launch table, codex error-text classification).

Verification

  • Final validation gate — live QA, all pass. Every provider through its graduated route with real CLIs: spawn, multiple turns, steer/stop/resume, fork, archived resume, models, staged bb skills reaching the CLIs, and accept-edits writes landing in thread storage. Plus the paths graduation specifically put at risk: plugin disable/re-enable, a plugin update with a new artifact hash mid-session, and a fresh install against an empty data dir. Process-tree and orphan sweeps clean; zero error events; CI green.
  • Multi-agent adversarial review of the graduation diff: 49 raw findings → 8 confirmed (every finding survived only if a dedicated skeptic failed to refute it), and all eight are fixed with red-verified tests. The sharpest class was the one that only exists after graduation: thread.archive and thread.unarchive carried no bridgeLaunch, so every graduated provider threw Unsupported provider — unarchive always (it runs on a fresh provider-maintenance runtime), archive whenever the thread's process was not already live — and the server's warn-log swallowed it. The launch spec is now threaded exactly like thread.start (wire field → server attach → daemon artifact resolve → runtime process key + adapter), and the codex account-restart re-resume carries it on ThreadRuntimeConfig too. The other six: codex never retracting thread/openWork on child exit (thread never idle-reaped); dynamic ACP agents offered while the ACP plugin is disabled; the boot race where the listener serves before plugins load (now a bounded registry settlement gate awaited by thread create, command building, provider listing and model loads); non-reactive execution-options reads in ThreadDetailView; and custom ACP agents labelled "ACP provider" in the skills library.
  • Earlier live QA, three full passes (pre-rebase, post-rebase, default-on gate): all four providers through the canonical bridges, verified by process trees and event streams, including a daemon-restart-under-stalled-server policy check.
  • Historical-bug audit: ~30 past provider fixes classified; every invariant is bridge-tested, shared-by-construction, or has a dedicated bridge-path suite (codex translator, ACP event-translation/interactions, pi noneoff, claude rate-limit classification), each verified red before landing green.
  • Dual-path calibration replay for all four providers while both paths existed: normalized event streams diffed by LCS, no unexplained parity divergence on any provider — only deliberate protocol differences. Post-graduation the suites remain as bridge-only self-consistency checks.
  • Conformance is all green for all five bridges — the codex zero-work-prompt pin was flipped, not deleted.
  • Full-tree typecheck and tests green; CI green.

Follow-ups (tracked, not in this PR)

  • Pi externals design — the directory-shaped artifact (bridge .mjs plus a pruned npm tree) that would let pi leave the daemon bundle; the empirical findings are recorded in the plan.
  • Auto-mode workspace gap — surfaced during the gate's live QA, independent of the provider surface.
  • Claude dropped-message-after-stop — a reproducible upstream-SDK behavior found during QA, written up as its own report rather than patched here.
  • Deferred phase-6 items, in two groups: (a) the three sessionless daemon→bridge reads — skills/scanRoots, provider/health|install|update, provider/usage — which share one transport and should land as their own wave; and (b) the four surfaces blocked on new plugin API rather than on consolidation — provider-scoped settings toggles, onboarding/settings entries, the provider-retry banner label, and provider icon colors. Both groups want a plugin-facing provider directory, which does not exist on either SDK surface today.
  • Smaller: env-var live-change semantics, fork-UI reactivity minors, ACP write-approval presentation.

AGENT GENERATED: by Claude Fable 5

ymichael and others added 30 commits August 14, 2026 21:30
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mer gap

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…where

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gences

- 87 protocol bumps (26->121), not 92; turn-settlement fixes are #1196/#1234/#1321/#1432
- #75 is a pre-GitHub ticket id; cite commit 1a5620b
- ProviderAdapter has 18 members, not 16; corrected per-provider line counts
- The outbound vocabulary is a shared 7-method core with real divergences
  (acp lacks fork; codex maps stop/discard/compact/skills to different
  methods and has 4 methods no bb bridge speaks) - phase 1 must pick
  canonical mappings; codex bridge is a mapping layer, not a passthrough
- Canonical PendingInteractionPayload union lives in @bb/domain, not
  shared/pending-interaction-normalization.ts (codex-only helper)
- classify split is claude-vs-rest; normalizeExecutionOptions is claude-only

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ances

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cleanup

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rtsSessionRestore

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… resume is same-process

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#1610

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…on 122

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d phase 6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ss model

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mmar

Phase 1 of plans/agent-provider-plugin-surface.md. Zod schemas for the one
runtime<->bridge JSON-RPC contract: versioned initialize handshake with
session-behavior capabilities (drift-proof: reported by the code that
implements them), canonical request set with stop intents and checkpoint-
aware fork, thread/event notifications carrying validated ThreadEvents,
mandatory session/replaced, droppable provider/raw, and canonical tool-call/
interaction shapes from @bb/domain. docs/provider-bridge-protocol.md states
the event grammar: turn/item lifecycles, bridge-minted id rules, ordering
guarantees, and the incident lessons each rule encodes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The kit (@bb/provider-bridge-protocol/conformance) drives any bridge
black-box over a lines-in/messages-out transport: JSON-RPC hygiene,
the initialize handshake, then a shared session lifecycle with grammar
checks (turn settlement, item-opens-before-delta, release-not-interrupted,
id uniqueness across resume). Transport-agnostic so in-process handleLine
bridges and spawned binaries both work; no test-framework dependency.

Calibration against the UNMODIFIED acp bridge pins the phase-2a work list
and surfaced a real finding: #859 gave the acp bridge request-vs-response
discrimination but reply-never-drop was never implemented there -
handleParsedMessage silently returns for unknown or schema-invalid
requests, so the hygiene rules fail today. Aliveness-dependent probes
report skipped (not false failures) on bridges that drop unknown methods.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One adapter for every protocol-pure bridge: constant command->canonical
method mapping, handshake capture via a post-initialize request (zero
runtime changes), capability gating that never sends unadvertised methods,
approval-policy ownership from the handshake, thread/event passthrough of
already-translated ThreadEvents, session/replaced surfaced as a visible
warning, canonical tool-call and interaction decode, and providerOptions
packing so provider-flavored execution fields cross the wire opaquely
during migration. classifyExecutionSettingsChange always reports live:
options ride every command and the bridge reconciles internally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… them

Phase 2a increment 1, fixing the conformance calibration's first finding:
decodeAcpBridgeJsonRpcRequest now returns discriminated failures, the known
method set is derived from the schema union so it cannot drift (#853), and
handleParsedMessage answers METHOD_NOT_FOUND / INVALID_PARAMS (with the zod
issues) where it previously returned silently - each drop was an
undebuggable 30s timeout on the runtime side. Calibration hygiene
expectations flip to pass; all 64 existing acp bridge tests unchanged.

Also fixes an invalid permission fixture (full mode requires
permissionEscalation null) in the kit defaults and adapter tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ymichael and others added 30 commits August 15, 2026 10:42
`list-commands.ts` reached into the Pi bridge's `configured-services.ts`
through an `@bb/agent-runtime` re-export for one settings helper, which is
the daemon importing a provider bridge's sources. The command scan is
daemon-local work and the daemon already depends on the Pi SDK, so the
eighteen lines are stated where they are used and the re-export is gone.

Independent of pi's graduation verdict (it stays daemon-bundled), this is
what stops the daemon depending on the bridge directory at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The second first-party bridge to graduate. packages/agent-runtime/src/
claude-code/** (and its event fixtures) moves verbatim to
plugins/provider-claude-code/src/**, the manifest gains
`bb.providerBridge: ./src/bridge/bridge.ts`, and `bb plugin build` emits
dist/provider-bridge.mjs — 2.44 MB, fully self-contained, and driven
end to end: run standalone with an empty PATH it answers model/list from
the artifact alone.

The @anthropic-ai/claude-agent-sdk inlines cleanly, unlike pi. Its one
package-relative resolution is the optional native CLI package, reached
only when `pathToClaudeCodeExecutable` is unset — and bb has always set
it from the host's own `claude` binary precisely because the bundled
bridge could not rely on that resolution. Nothing else in the bridge
reads its own module path, and the MCP tool proxy is in-process.

Two prerequisites, both landed here:
- the packaged-daemon sentinel in host-daemon/src/index.ts moved from
  bb-claude-code-bridge.mjs to bb-pi-bridge.mjs. Pi's bridge is the one
  that stays daemon-bundled, so it is the file that outlives every other
  first-party bridge and the only stable sentinel.
- shared/permission-policy.ts was the last shared module with a live
  consumer on each side of the boundary. It has one home now — the
  bridge kit — and both the runtime and the claude bridge import it from
  there, so agent-runtime never imports the plugin and the policy is not
  restated.

End of the claude-code carve-outs: the bundle-manifest target, the
bb-app `files` entry, the launcher presence assertion, the registry
branch, and the provider-catalog capability and session-restore
baselines. The tarball smoke drives the packed plugin artifact and
expects provider-claude-code to reach "running" in the packed install.

Dropping claude-code from the restorable seed table makes the
`sessionRestorable` a bridge reports on thread/start the only thing that
lets the idle sweep release a graduated provider's session — an
untested path until now, so the process-lifecycle reaper tests report it
on the wire instead of relying on the shrinking seed.

268 claude-code tests (bridge, conformance, calibration, translators,
interactions, session params, visibility) run as
bb-plugin-provider-claude-code#test, which CI's packages shard picks up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The last first-party migration. packages/agent-runtime/src/acp/** moves
verbatim to plugins/provider-acp/src/**, the manifest gains
`bb.providerBridge: ./src/bridge/bridge.ts`, and `bb plugin build` emits
dist/provider-bridge.mjs — 919 KB, self-contained (no hono reaches it
from @bb/host-daemon-contract), and run standalone with an empty PATH it
answers model/list with its synthetic "Agent default" list.

ACP's blocker was routing, not bundling. Only the ids bb declares itself
(`acp-cursor`) are plugin-registered; every known agent and every
`customAcpAgents` entry is resolved from a launch spec at request time
and never registered at all. `resolveBridgeLaunchForProviderId` served
only registered ids, so those agents would have graduated into having no
bridge whatsoever. It now resolves the ACP tier explicitly: an
unregistered `acp-*` id borrows the artifact of whichever plugin declares
ACP and takes its capabilities from the shared tier, the same fallback
every other ACP policy accessor on the registry already uses.

The launch spec keeps riding exactly as before — the `acpLaunchSpec`
command field into the daemon, then the provider-scoped static options
into the bridge — so configured agents, known agents, and acp-cursor
(whose spec has no server-side entry and still comes from the runtime's
built-in table, now `acp-launch-specs.ts` beside its fingerprint) all
launch unchanged. The registry's ACP branch is gone; the generic plugin
route packs the launch spec, the plugin's own bag, and the environment
write roots into one static bag.

Pi is now the only bundled bridge: provider-catalog is down to one entry,
the daemon bundle manifest to one bridge target, and the runtime's
last import of an ACP module is gone (the steer-stale check reads the
canonical `BRIDGE_JSON_RPC_ERRORS.NO_ACTIVE_TURN`, which is the same
-32001 the ACP constant restated).

Two test-side consequences, both real coverage rather than fixture churn:
- the integration harness now builds and records the first-party bridge
  artifacts like the plugin runtime does, because without a bridgeLaunch
  a graduated provider has no bridge at all. The dynamic-ACP smoke — the
  one test that drives a configured `acp-<slug>` agent end to end —
  therefore exercises the real artifact route, and it passes.
- the host-daemon-contract bridge-launch round-trip was left red by the
  codex commit (it predates the three declared thread capabilities on the
  wire); it is green again.

143 acp tests run as bb-plugin-provider-acp#test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The live-CLI integration suite calls the runtime directly, so nothing
played the server's part of attaching `bridgeLaunch`. Every graduated
provider therefore had no bridge at all and failed with "Unsupported
provider" -- 53 failures once codex, claude-code, and acp had shipped as
plugin artifacts.

The harness now does what the plugin runtime and the server do, and
nothing is stubbed: a vitest global setup builds the three plugin
bridges from source with @bb/plugin-build, captures each plugin's
declaration through the same capture stub + validation the server test
helper uses, and records {sha256, artifactPath, capabilities} in a
manifest. `createTestRuntime` reads it synchronously and injects the
launch into the five entry points that can start a provider process,
unless a test passed its own. Pi has no entry: its bridge stays in the
daemon bundle, which is exactly the `isBundledProviderId` predicate the
production registry routes on.

53 failures -> 10, and all 10 reproduce on the pre-graduation tree
(e11221d^) with the same real CLIs: nine live readonly/auto
permission scenarios where current codex and claude CLIs no longer
restrict the workspace, plus one pi resume that loses its context. One
codex readonly-deny case that fails pre-graduation now passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Archive and unarchive spawn the provider bridge on their own — unarchive
always runs on a fresh provider-maintenance runtime, and archive has no
live process once the thread's session was reaped — so a graduated
provider (every first-party bridge except Pi) had no bridge to launch and
`createProviderForId` threw `Unsupported provider "codex"`.

Thread the launch spec end to end, exactly as thread.start does: the wire
commands carry it, the server attaches it from the provider registry, the
daemon resolves the cached artifact, and the runtime keys the process and
builds the adapter from it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The restart shut the thread's codex process down and re-resumed from
ThreadRuntimeConfig, which stored no bridge launch — so the re-resume
derived an un-suffixed process key and built the adapter with no launch,
which throws for a graduated provider and leaves the thread with no
process at all.

Store the launch on the thread's runtime config at start and resume, and
re-resume with it. Archive/unarchive falls back to it too, so a live
thread keeps working when the caller has none.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`thread/openWork` is level-triggered on the runtime side, so a claim
raised for a native subagent stood forever once the app-server child
exited: the idle-session reaper refuses any thread with open work, and
the stale tracked subagent would have re-raised the claim anyway.

Child exit now drops the state that only describes a live child (raw
command output, native-subagent tracking) and reports open work again,
which retracts the claim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Known ACP agents and every customAcpAgents entry are composed into the
provider listing straight from config, independently of the registry —
but since the ACP bridge graduated to a plugin artifact they have no
bridge of their own: they borrow the registered ACP plugin's. With that
plugin disabled the picker still listed them and the first turn died on
the daemon with "Unsupported provider".

Gate the dynamic ACP tier on a registered ACP provider plugin, which also
skips the known-agent host probe when there is nothing to offer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Providers come only from plugin registrations now, and the HTTP listener
deliberately serves before plugins load. In that window the registry is
empty: creating a thread threw the 409 "no provider available", the
provider list came back empty, and a turn on an existing thread dispatched
with no bridgeLaunch and died on the daemon as an unsupported provider.

The registry now exposes a settlement gate the server resolves when plugin
startup finishes (or fails), and the provider-routed paths — thread create,
thread.start/turn.submit command building, provider listing and model
loads — wait for it. The wait is bounded so a stuck plugin (or a plugin's
own loopback SDK call during startup) cannot wedge requests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…providers

Two app-side reads of provider identity were wrong in the same way: they
assumed a provider id is enough, or that a cache read re-renders.

ThreadDetailView gated fork and edit-message on a render-time
execution-options cache read, but it never mounts that query (its composer
child does), so on a deep link the affordance stayed hidden until an
unrelated query re-rendered the tree. It now subscribes to the cache.

The skills library labelled providers from the icon's per-tier aria label,
so every custom ACP agent rendered as "ACP provider" — in the filter menu,
the scope label, and the search text. It now names them from the server's
provider roster, with the icon label as fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Picks up thread.archive / thread.unarchive carrying bridgeLaunch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The provider declaration advertises fork coarsely for UI affordances and
the handshake is the operative truth, but the runtime read only the
declaration: a fork request was built and dispatched no matter what the
bridge said. A bridge advertising fork "none" had no obligation to
reject it, and a "tip" bridge got checkpoint forks it can only fail.

The adapter now rejects both before dispatch with a legible error, and
the start path builds its plan inside the try so a rejected fork takes
the same failed-session-construction cleanup as any other start failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n release

Two halves of the same leak. The process key carried the artifact hash but
not the declaration facts the adapter is built from at spawn — capabilities
and the static provider option bag — so a plugin that edits its declaration
without rebuilding its bundle kept serving new threads from the superseded
adapter. Both now ride the key (and the daemon's model-list runtime cache).

And the stale-hash sweep only ran when a process was ensured, so a
superseded process that still owned a thread was skipped and never
revisited: losing its last thread left it running until daemon shutdown.
The release path, which already retired thread-scoped codex processes,
now retires superseded bridge processes as well.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The conformance kit checks the grammar statically, against bridges someone
ran it against. Bridges now ship as plugin artifacts and can be
third-party, so nothing checked their streams at all: a delta into an item
no item/started opened, an item settled twice, or a turn/completed for a
turn that never started went straight into runtime state and a persisted
timeline.

ThreadEventGrammar is that grammar as a streaming state machine, in the
protocol package so the kit's item/opens-before-delta rule is now this
machine fed a log — one implementation, same rule ids. The runtime runs it
at intake and drops violations with a warning naming the rule; it replaces
the turn replay filter, whose completed-turn tracking it subsumes. An item
that settles without opening is kept: it carries the whole item, so
dropping it would lose content.

Ownership: the single-thread fallback in event thread-id resolution now
refuses an id that names another live thread, instead of filing one
thread's work into another's timeline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two real bounds and one deliberate non-bound.

A provider bridge bundle is now third-party plugin output, and the daemon
buffers it whole to hash-verify it before executing it — with no ceiling
anywhere, so a large bundle was unbounded daemon memory. The cap lives on
the wire contract and is enforced on both ends: the server refuses to
record an oversized artifact (so it is never addressable) and the daemon
refuses one before a byte is fetched.

Both ends of the bridge pipe read JSON-RPC with `readline`, which has no
maximum line length: a runaway or never-terminated line grew a buffer
until the process died. readBoundedLines is readline with the bound it
lacks — it discards the oversized line, reports it, and resumes at the
next one — and it strips CR, which the stdout path never did.

Left as is: the daemon event-sink queue is unbounded on purpose (it holds
every host thread's events across a delivery stall and must not drop
them); its tripwires already warn when it backs up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A declaration registered a listable, thread-creatable provider on its own
word. Nothing checked that anything could run it: a plugin whose bridge
build failed loaded as "running" with the failure as a status detail and
its provider still in the picker, and a router-kind declaration — which
by contract has no bridge, and which nothing anywhere resolves — did the
same by construction. Either way the first turn died on the host with
"Unsupported provider".

Registration now requires an implementation: this load built a bridge
artifact, or the id is one the daemon bundles a bridge for (pi, now named
once on the contract both the server and the runtime read). Without one
the plugin load fails, naming the reason, and contributes no provider.

Two dead contract fields go with it. `kind` had one usable value, since
`router` had no resolver; `bridge.entry` was path-validated and then
never bound to anything — the manifest's `bb.providerBridge` is the real
entry, and all four first-party plugins passed the placeholder string
"provider-bridge". Routers return when their resolver does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Collision rejection only covers ids that are registered right now, so with
the official plugin disabled — or failed, or not yet loaded — any plugin
could claim "codex", "claude-code", "pi", or an "acp-" id. For pi that is
worse than a name: the runtime refuses artifact routing for daemon-bundled
ids, so a third-party "pi" would supply the metadata, capabilities, and
provider options while bb executed its own bundled pi bridge underneath.

Those ids are now reserved to the plugin that owns them, checked at
declaration time (so a staged registration fails the factory) and in the
registry (so a live one throws), whether or not the owner is loaded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lout

The echo-provider example drops the two deleted declaration fields, and
the generated plugin-SDK d.ts is regenerated (it also picks up an
unrelated enum-ordering drift that was already stale on the branch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The legacy pi adapter is gone, so the bridge's second dialect had one
driver left: the tarball smoke, which still sent `{clientInfo}`,
option-less `thread/start`, intent-less `thread/stop`, and read
`sdk/message` notifications. The smoke now drives the canonical protocol
for every bridge it touches (handshake, session construction, turn
start, release stop) and asserts canonical `thread/event`s — the pi E2E
waits for `turn/completed{status:"completed"}` rather than an SDK
`agent_end`, so an interrupted or failed turn fails loudly instead of
satisfying the wait.

With that, `PiSessionDialect` and everything it gated goes: the nullable
per-session translator, the legacy `thread/identity` re-emissions, the
bare-`error` notification arm, the duplicate legacy `handleTurnStart` /
`handleTurnSteer` / `handleThreadStop` entries, and the six legacy param
schemas. Deleting the legacy union arms also closes a real hole: the
legacy schemas were non-passthrough and sat *second* in each union, so a
canonical request that failed validation for any reason fell through to
them, got `options` stripped, and was silently served in the legacy
dialect instead of answering INVALID_PARAMS.

Session construction collapses from four layers to one.
`buildPiCanonicalSessionParams` used to emit an untyped record shaped
like the legacy wire, which was re-parsed through the legacy zod schema,
mapped again by a second `buildPiSessionParams`, and only then became SDK
options — and the env vars made a full round trip out to
`shell_environment_policy.set.*` config keys and back via
`extractEnvOverrides`. One typed `buildPiSessionParams` now produces the
session params directly, with `shellEnvOverrides` built once by the new
kit helper `buildShellEnvOverrides` (which `buildShellEnvironmentPolicyConfig`
now delegates to, so the filtering rule lives in one place). The legacy
`sessionPath` override, reachable only from the deleted resume shape,
goes with it.

Tests follow: the pi bridge suite drives canonical params through shared
`sessionParams`/`turnStartParams`/`threadRef` helpers and asserts
translated thread events. The skill-path test folds into the
`skills/configure` test, which is the only way skills reach a session now.
Nothing has driven the ACP legacy dialect since the adapter was deleted:
the runtime's bridge-protocol adapter always sends canonical params, and
the tarball smoke now speaks the canonical handshake. So the per-session
`AcpSessionDialect`, the nullable translator, the `emitForSession` /
`emitSessionError` raw-notification arms, the deferred-emit legacy
branch, and every legacy union arm in `acpBridgeCommandSchema` are gone.
The `acp/*` envelopes stay — they are the translator's input vocabulary,
not wire traffic, and the module header now says so.

Three paths die outright rather than collapse:

- The `acp/permission/request` runtime request. The canonical
  `interaction/request` carries the same approval, and the legacy
  request's params schema already had no importer at all.
- `thread/compact`. The handshake reports `manualCompaction: false`, so
  the runtime never sends it; the method existed only in the legacy
  dialect. `startCompaction`, the compaction notification methods, their
  schemas, and the translator's two compaction arms go with it, and
  `activePromptKind` becomes the boolean `turnActive` it always was once
  compaction was the only other value.
- The internal params zod round-trip. `buildAcpSessionParams` and
  `buildAcpModelListParams` produced untyped records that were
  immediately re-parsed through the legacy wire schemas; they now return
  the typed `AcpSessionParams` / `AcpModelListParams` interfaces they
  always described, and the three near-identical start/resume/fork
  handlers collapse into one. `model/list` loses its two-branch decode:
  a request without a launch spec decodes to a null profile, which is the
  same synthetic-default degradation the old legacy branch produced.

The bridge test suite drove the legacy dialect end to end, including its
model-list params, so it moves to the wire the bridge now speaks: a
launch spec in `options.providerOptions`, canonical turn and stop params,
and assertions on translated `thread/event`s instead of `acp/*`
notifications. Model discovery now runs the agent binary itself, which is
what a launch-spec-derived list command always does, so the fake agent
grew a `--list-models` mode. One test is new: session construction
without a launch spec must fail with INVALID_PARAMS, which is the one
degradation the bridge must not make.
Codex graduated first, so it carried no dialect branching to remove. What
was left was the legacy probe arm on `initialize` — kept alive only by the
tarball smoke, which now sends the canonical handshake — plus a set of
seams whose reason to exist was the deleted adapter.

`handleThreadStart`, `handleThreadResume`, and `handleThreadFork` were
three copies of the same nine lines differing only in the construction
request and one boolean; they become one `handleThreadConstruction` whose
`resumable` reading is derived from the request kind, which is what the
boolean always meant.

Two modules existed only because two consumers were expected.
`permission-mapping.ts` had exactly one importer (`interactive-requests.ts`)
and no test of its own; merged, all seven of its exports become locals.
`subagent-activity-translation.ts` had exactly one importer
(`translator.ts`), no test of its own, and could never gain another — the
sub-agent tracking state lives entirely in the translator's closures.
`CodexSkillsExtraRootsSetParams` had no reference anywhere.

Fifteen more exports were module-internal in practice and are now
declared that way. Three that looked the same are deliberately kept
exported: `combineWorkspaceWriteRoots`, `toCodexReasoningEffort`, and
`mapCodexReasoningLevelToBb` are pure functions with real unit tests, and
routing those assertions through a caller would test less, not more.

The remaining changes are comments: the module headers and several
inline notes still explained themselves in terms of "shared with the
legacy adapter" or "mirrors the legacy path", which is now a claim about
code that does not exist.
The legacy claude-code adapter is gone and the tarball smoke now speaks
the canonical handshake, so `ClaudeSessionDialect` had no driver left.
With it go the nullable per-session translator, the raw `sdk/message`
forward in `emitForSession`, the bare-`error` notification arm, the
non-canonical `emitCanonicalSessionReplacement` guard, the dual identity
result shapes, and every legacy params arm in `claudeCodeCommandSchema` —
including `isCanonicalDialectParams` and `selectDialectBranchIssues`,
whose only job was picking which union branch's issues to report.
`sdk/message` stays as the translator's input envelope; it just never
reaches the wire.

The interaction path collapses to one shape. A pending request's
`canonicalPayload` was nullable only so legacy entries could carry
Claude-native shapes; it is now the required `payload`, and the two
`item/permissionRequest/approval` / `item/userQuestion/request` tails
are gone — every approval and question rides `interaction/request` with
a canonical payload and comes back as a canonical resolution.

Two seams that only the legacy wire could reach die with it:

- `inputGroups`. Canonical `turn/start` and `turn/steer` carry one flat
  input list and `buildClaudeTurnParams` never emitted groups, so the
  multi-prompt queueing path in the steer handler and `buildPromptTexts`
  were unreachable.
- A `thread/resume` with a null `providerThreadId`. Canonical resume
  names the session it reopens; the bridge now answers INVALID_PARAMS
  instead of quietly starting a fresh session.

Session construction merges: `buildClaudeCanonicalSessionParams` decoded
the providerOptions bag and delegated to `buildClaudeSessionParams`,
which is now that one function; the three construction handlers share
`toClaudeSessionParams`, and the canonical turn handlers absorb the
internal ones (`acceptance` stops being optional, so its three
`!== undefined` guards go).

Tests follow the wire. The bridge suite drives canonical params through
shared `canonicalOptions`/`canonicalTurnParams` helpers and asserts
translated payloads. Three readonly-Bash cases and one policy case drove
Claude's `default`/`dontAsk` modes, which no canonical permission policy
maps onto — the hook-level readonly suite in the same file already covers
that rewrite in depth, so the unreachable wire cases go and the surviving
cases name the policy they construct rather than a Claude mode. The two
`buildClaudeCanonicalSessionParams`-vs-`buildClaudeSessionParams`
equivalence assertions became tautologies when the functions merged and
are replaced by absolute assertions on the knobs they were guarding.
The canonical thread/compact request had no sender: the runtime adapter
never built a compaction command, so pi's and codex's handlers were dead
(ACP's was already deleted). Manual compaction actually travels the prompt
path — a standalone builtin /compact mention through the normal turn
pipeline, which codex maps to thread/compact/start.

Removed the method constant and params schema, both bridge handlers and
their command-schema union entries, and the pi bridge test that existed
only to exercise the method. The manualCompaction handshake fact and the
declared supportsManualCompaction stay: they gate the /compact affordance,
and the protocol doc now records that the trigger is prompt input and a
structured request is future work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bb's manual compaction is a prompt-path affordance: the composer sends a
standalone builtin `/compact` mention as turn input. The codex bridge
intercepts it in turn/start; the pi bridge did not, so the literal text
`/compact` went to the model. Live on openai/gpt-5.4-mini that produced an
assistant message claiming compaction while context grew, and no
`thread/compacted` event — pi's own `/compact` is an interactive-mode command
the SDK path never sees, and `PiSdkSession.compact()` had no caller.

turn/start now classifies the input with `isStandaloneBuiltinCompactCommand`
and drives the SDK compaction. The translator already turned pi's
`compaction_start`/`compaction_end` into the maintenance turn, and the prompt
settle report closes the requested turn when pi refuses to compact and emits
no events at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bb's manual compaction is a prompt-path affordance: the composer sends a
standalone builtin `/compact` mention as turn input. On origin/main the ACP
adapter intercepted it and sent `thread/compact`, which the bridge ran as a
provider-local maintenance prompt — `session/prompt` with `/compact`, which is
how OpenCode exposes its built-in compaction over ACP. Graduation deleted the
sender; the canonical-protocol simplification then deleted the handler,
reasoning from the bridge's own `manualCompaction: false` handshake. That fact
was stale bookkeeping, not the gate: wave 4 made ACP compaction a per-agent
server-side declaration, and `providerRegistry.supportsManualCompaction` is
what admits the compact action. So an `acp-opencode` `/compact` still reached
the bridge and went to the model as literal text.

`turn/start` now classifies its input with `isStandaloneBuiltinCompactCommand`
and drives the maintenance prompt, with `activePromptKind` (turn | compaction |
null), the compaction envelopes, and the translator's two arms back with it.
Only an `end_turn` prompt reports `thread/compacted`; every other stop reason
or a rejected prompt fails the turn with the agent's own reason.

Per-agent honesty stays server-side. An agent's `available_commands_update`
cannot serve as the gate: OpenCode lists only its custom commands there and
never its built-in `compact`. The handshake now reports `manualCompaction:
true` — a process-level fact about what this bridge implements, decided before
any session exists — and the protocol doc and schema now say plainly that
nothing gates on that field.

Verified live on acp-opencode: contextCompaction item, `thread/compacted`,
completed turn, context 10,612 -> 2,491 tokens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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