Narrow-grammar provider bridge protocol: bridges emit semantic deltas, the runtime assembles the timeline (v2) - #1834
Conversation
|
🚨 SLOP COP 🚨 · I am SlopCop, and I am reviewing this pull request now. I will check security, code quality, performance, architecture, duplication, and end-to-end behavior. |
| providerItemId: piEvent.data.toolCallId, | ||
| ...parentRefField, | ||
| }, | ||
| text: snapshot, |
There was a problem hiding this comment.
🚨 slopcop/review — Diff Pi command output before JSON transport.
The bridge sends every full cumulative snapshot. The runtime then finds the suffix. This makes bridge traffic quadratic. A 1 MiB output with 1 KiB updates sends about 512 MiB.
Keep the prior snapshot in the Pi bridge. Send only the suffix and a reset flag.
|
|
||
| /** | ||
| * A session announces identity before any `thread/event`. Pi sessions always | ||
| * A session announces identity before any `thread/delta`. Pi sessions always |
There was a problem hiding this comment.
🚨 slopcop/review — Reset the runtime assembler after each new Pi session.
The process-wide assembler survives session replacement. Send session.reset after the identity and before session deltas. Without it, stale IDs, settled keys, and token totals can return.
| const parsed = threadDeltaNotificationParamsSchema.safeParse( | ||
| message.params, | ||
| ); | ||
| if (!parsed.success) { |
There was a problem hiding this comment.
🚨 slopcop/review — Fail conformance when delta parameters do not parse.
Returning an empty event list hides invalid bridge output. A bridge can emit invalid notifications and still pass the conformance schema check.
| const parsed = initializeResultSchema.safeParse(result); | ||
| if (parsed.success) { | ||
| handshake = parsed.data.capabilities; | ||
| if (!parsed.success) { |
There was a problem hiding this comment.
🚨 slopcop/review — Fail the required initialize request when this parse fails.
The current return starts a malformed or incompatible bridge with default capabilities. Throw the parse error so provider startup stops with a clear error.
| * `thread/event`. | ||
| */ | ||
| export const PROVIDER_BRIDGE_PROTOCOL_VERSION = 1 as const; | ||
| export const PROVIDER_BRIDGE_PROTOCOL_VERSION = 2 as const; |
There was a problem hiding this comment.
🚨 slopcop/review — Bump the host daemon protocol with this bridge protocol.
This wire change can reach an older enrolled daemon. That daemon ignores thread/delta and shows an empty timeline. Increment HOST_DAEMON_PROTOCOL_VERSION so machines update first.
| * parent tool call for nested items. The assembler translates all of these to | ||
| * bb-minted ids. | ||
| */ | ||
| export const deltaItemKeySchema = z.object({ |
There was a problem hiding this comment.
🚨 slopcop/review — Use collision-safe item and stream keys.
This schema permits {} and NUL in each field. The assembler uses NUL separators and root as a sentinel. Valid inputs can combine unrelated items or streams.
Require one key field. Use a structured key format that cannot collide with valid field values.
|
|
||
| const translator = createSessionTranslator(); | ||
| // Ordering guarantee: thread/identity precedes any thread/event for the | ||
| const translator = createAcpDeltaTranslator(); |
There was a problem hiding this comment.
🚨 slopcop/review — Reset the runtime assembler after each new ACP session.
This new translator no longer owns the ID state. The process-wide assembler does. Send session.reset after identity and before deferred deltas, or a replacement session can reuse stale state.
There was a problem hiding this comment.
🚨 SLOP COP 🚨 · review
Plain English summary: Providers now send small timeline updates. One shared runtime component turns those updates into the events that users see. This removes four repeated provider state machines. It also changes the bridge protocol from version 1 to version 2.
I would not merge this revision yet. I found two release blockers and several state errors. I posted this as a comment-only review.
The main findings are:
-
The host protocol does not change.
PROVIDER_BRIDGE_PROTOCOL_VERSIONchanges to 2, butHOST_DAEMON_PROTOCOL_VERSIONremains 132. An old daemon can run a new bridge and ignorethread/delta. Users then get an empty timeline. -
The packed-package smoke client still uses version 1.
packages/bb-app/scripts/smoke-tarball.mjsstill waits forthread/event. The full Turbo smoke test failed after 10 minutes and 56 seconds at the Pi installed-package check. -
Pi and ACP do not reset shared state for a new session. The shared assembler can reuse old item IDs, settled keys, and token totals. A forced ACP stop makes this risk larger.
-
The assembler can produce wrong timeline links. It emits old progress before
session.reset. A child-first item keeps a raw parent ID. The eviction rule can remove active turn maps and pending accepted input. -
The initialize and key checks are too weak. A malformed initialize result starts with default capabilities. Empty and NUL-based keys can collide and combine unrelated streams.
-
Pi command output can cause quadratic bridge traffic. Pi sends each full output snapshot through JSON. The runtime computes the suffix only after transport.
-
Public support files still describe version 1. The echo provider example does not typecheck. The built-in author skill still tells plugin authors to send
thread/event.docs/codex-app-server.mdalso links to a removed file. -
The conformance helper hides malformed deltas. It converts an invalid notification to an empty event list. The bridge can then pass the event check.
Security review found no command, path, secret, or host-field injection issue. However, the identity defects can connect an event to the wrong item or turn.
The central assembler removes useful duplication. It now has 1,875 lines and combines ID maps, turns, streams, progress, and usage. Small pure reducers would make each rule easier to test. The file also contains literal NUL bytes, so Git treats it as binary. Replace them with escaped text.
Validation results:
- The protocol, runtime, Codex, Claude, and ACP tests passed. They ran 70, 387, 163, 259, and 144 tests.
- The agent runtime Turbo typecheck passed.
- Chromium loaded the source app. A real Codex development turn returned
ok. - The full
smoke:tarballtask failed at its stale Pi bridge client. - The echo provider Turbo typecheck failed on the removed version 1 API.
The independent review gate confirmed the eleven defects above. It merged the empty-key issue with the key-collision issue. It treated the literal NUL bytes as a refactor.
bd07bed to
ba6753b
Compare
…spec) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prototype cut of the semantic-delta grammar from plans/narrow-grammar-protocol.md: a discriminated union of parsed deltas (input/turn/item/message lifecycle, snapshots, usage, context window, errors, unhandled, session settlement) plus the thread/delta notification params. Additive only — thread/event bridges and the protocol version are untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
createDeltaAssembler owns the timeline half of the narrow-grammar split: central turn/item id minting (entropy+serial, both-way provider<->bb item maps), the accepted-input queue with drain-on-turn-open and claim-if-idle terminal rules, delta-first item/started synthesis, open/close pairing with close-echo of started fields, provider-final-vs-accumulated message text, cumulative command-output snapshot diffing (absorbing pi's diff-cumulative-text), running usage totals, currentOrLast attachment, and session-ended settlement of open turns and items. translateEvent in the bridge protocol adapter now routes thread/delta notifications through it; thread/event bridges are untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pi's translator shrinks to dialect parsing (delta-translation.ts): schema narrowing, bash/edit/write classification, placeholder stripping, the ignored-event set, visibility-driven unhandled deltas, and the model context-window catalog. It is stateless — the per-session turn-state registry, scoped-item-id factories, accepted-input queue, snapshot diffing, cumulative-token accumulation, and entropy id minting all moved into the runtime assembler. Bridge lifecycle sites now speak deltas too: interrupt emits session.ended instead of a hand-built turn/completed, prompt-settled and agent_end emit claimIfIdle boundaries, turn/start and steer emit input.accepted, and session errors ride a settling provider.error. Bridge and conformance tests assemble the captured thread/delta notifications through a real delta assembler (the runtime adapter's exact translation), so the canonical protocol suite still passes end to end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The same provider fixtures that drove the old event-translation suite now drive the new pipeline (pi dialect -> deltas -> runtime assembler -> canonical ThreadEvents). Content, ordering, scoping, and statuses are asserted exactly as before; ids are asserted by shape and stability since minting moved to the assembler. Deliberate deviations are marked inline: compaction_end with no known turn is dropped instead of unhandled, and tool events without agent_start open an implicit turn instead of surfacing as unhandled. Adds lifecycle coverage the old suite could not express centrally: prompt-settled claim-if-idle, prompt-settled failure, and session-error settlement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Line deltas, the grammar gaps the pi conversion surfaced (item.progress, currentOrLast item attachment, three-way message.close, parentRef on streams, unhandled rawType), the behavior deviations the grammar cannot express, and the assessed acp/codex/claude conversion costs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Item and stream deltas no longer open turns implicitly: only turn.open, a
claiming turn.boundary, and accepted-input lifecycle settlement may. A
turn-requiring delta arriving with no open turn now surfaces its new
optional noTurnFallback { raw, rawType } payload as a thread-scoped
provider/unhandled — exactly the old pi translator's buildUnexpectedPiSdkEvent
guard — or drops silently when the bridge attached none (old pi's
coverage-filtered silence for turnless message updates). Pi attaches the
fallback to tool_execution_* and compaction deltas, so turnless tool events
and turnless compaction_end match the old behavior byte-for-byte and the
equivalence suite's deviation markers are gone. contextWindow attach:"open"
likewise attaches to the open turn instead of fabricating one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r gaps item.close now REQUIRES the full terminal item shape and the assembler builds every completed item from it: a same-shaped open item contributes only its minted id, a different-shaped one settles first and the terminal shape follows under the same id (ACP's dual-complete), and close-without-open builds the bare item. Pi replays the shape it classified at tool_execution_start from a per-call cache (the end event omits args) and drops the cache when the turn settles. Grammar additions for the ACP conversion: fileChange shapes carry an explicit multi-entry changes list with stated kinds, turn.plan mirrors turn/plan/updated, provider.warning takes vouchedTurn turn scoping, unhandled takes onlyIfNoTurn (the old "known event, no active turn" visibility fallback for events that otherwise translate to silence), and message.close releases the stream on every settle with empty-after-trim suppression for accumulated text. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bridges that speak the narrow grammar need the delta schemas, types, and notification method from @get-bb/plugin-sdk/provider-bridge; the acp plugin is the first out-of-runtime consumer. Bundled types regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The acp translator shrinks to dialect parsing + delta emission (event-translation.ts 1,135 → delta-translation.ts 845): the internal acp/* envelopes now map to thread/delta semantic deltas and the runtime assembler owns turn/item id minting, accepted-input correlation, stream accumulation, pairing, and settlement. The bridge keeps only its dialect state — the tool-call merge cache (updates inherit absent fields) — and stamps provider conclusions onto deltas: stop-reason turn boundaries, stream-flush closes at the message/tool/turn-end trigger points, and terminal item.close shapes drained from the merge cache at turn end (their close fields come from merged raw output the assembler cannot reconstruct). Turnless known updates surface through noTurnFallback / onlyIfNoTurn exactly as the old no-active-turn guard did. Permission interactions no longer read a translator turn id: the bridge sends the wire contract's unresolved marker (turnId: null) and the runtime stamps its active turn. fs/write envelopes carry oldText/content instead of a pre-built diff so the assembler constructs the identical diff centrally. The acp translation suite is ported as equivalence evidence (same envelopes → deltas → a real assembler → exact canonical events; ids by shape), and the bridge + conformance suites assemble the captured thread/delta notifications through @bb/agent-runtime's test-only bridge-delta-assembly path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Grammar: vouched provider-turn keys (multiplexed keyed turns that bypass the current-turn machinery), item-keyed text/output delta kinds with the structural no-synthesis rule for command/fileChange output, richer item shapes (agentMessage/reasoning/plan/webSearch/webFetch/imageView, tool server/result/error/durationMs, fileChange movePath/provider diff), terminal approvalStatus, exact usage fan-out, turn diffs, thread metadata deltas, normalized rate-limit snapshots, structured errorInfo with vouched/thread scoping, and session.reset as the provider id-space boundary. Assembler: generic settle/reopen dedup for provider-identified items (channel-keyed families exempt) with bb-id reuse on explicit reopen, plus both-way provider<->bb turn maps for command-plane reverse lookup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Central minting means a delta bridge holds no bb ids: the adapter now translates steer expectedTurnId and interrupt activeTurnId to provider-native turn ids through the assembler's reverse maps (bb ids pass through unmapped for thread/event bridges and delta bridges without native turn ids), and inbound interaction/tool-call requests marked providerNativeIds get their turn id, approval-subject item id, and call id translated onto the assembler-minted ids the app's timeline carries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codex's native turn/item notifications now map ~1:1 onto thread/delta semantic deltas: delta-translation.ts replaces event-translation.ts and carries codex ids verbatim as vouched join keys (providerTurnId, key.providerItemId); the rate-limit snapshot merge stays bridge-side (seeded by the per-child post-initialize read). translator.ts keeps every stateful closure — raw shell-output recovery (now buffering item.close deltas), delegation/subagent FIFO parent-linking, accepted-turn correlation (input.accepted rides the drained turn.open), git-root staging — but its id stamping and canonical event construction moved to the assembler. The bridge deletes the entropy-prefix id layer, the settle/reopen dedup sets, and the delta-first synthesis (all assembler work now), emits session.reset at every construction as the provider id-space boundary, settles child-exit turns with keyed turn.boundary deltas off the open-turn set it keeps for zero-work gating, and forwards interactive/tool-call requests with providerNativeIds so the runtime translates approval subject ids. Steer/interrupt use their ids verbatim (the runtime reverse-maps); the legacy prefix strip survives only for fork checkpoints persisted before the cutover. Equivalence evidence: the event-translation and translator suites are ported to drive the same codex fixtures through deltas and a real assembler (delta-translation.test.ts, translator.test.ts); the calibration golden, zero-work, child-exit, and full conformance suites run end to end on the new path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sign Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Grammar: a backgroundTask item shape (full snapshot re-embedded per event, exactly today's canonical payload), snapshot/flush fields on item.progress, provider.modelFallback, and webFetch prompt. Assembler (generic only): central progress-event throttling — one emission per item key per policy interval (constructor option, 500ms default, seeded at item.open, flush bypasses, trailing-edge flush of the newest suppressed snapshot on later thread traffic, item.close supersedes) — background-task family events derive their thread scope structurally from the domain grammar (started is turn-scoped, progress/completed thread-scoped, closes need no open turn), thread-attached items survive turn settlement and session-ended settlement, the LRU eviction guard pins threads with open items or open turns, and webSearch/webFetch closes honor the generic resultText close field. No per-provider assembler extension: the claude task machine's dialect half (workflow fold, generations, completion blocking, interruption drains) stays bridge-side and rides these generic deltas. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…utover The provider bridge artifacts a server serves to daemons now speak bridge-protocol v2 (thread/delta only). An old daemon's runtime would ignore those notifications and render empty timelines, and old runtimes predate the bridge-handshake version check, so the daemon protocol version is the only gate that forces the daemon update. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The third-party proof-of-concept bridge still emitted thread/event, which no longer exists in bridge-protocol v2, so it neither typechecked nor passed its conformance suite (the CI Checks and Tests(packages) reds). It now emits minimal thread/delta batches — input.accepted, turn.open, a streamed assistant message, turn.boundary — plus a session.reset at every session construction, and answers the v2 handshake. Its conformance transport runs the deltas through a real runtime delta assembler on the kit's assembledEvent lane, like the first-party bridges. The example was already on the turbo board (workspace globs cover examples/plugins/*), so its typecheck and test gate CI again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The smoke drives the pi bridge directly over the runtime-bridge wire, which now speaks thread/delta only; waiting for thread/event notifications timed out the Pi installed-package configuration E2E (the Package Smoke CI red). The client now initializes with protocol version 2, waits for the completed turn.boundary delta, and asserts both configured tools settle as completed item.close tool shapes — the same assertions it made against canonical events before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
codex and claude-code already mark the provider id-space boundary with a session.reset delta at each session construction; pi and acp did not, so the shared assembler could reuse settled item keys, id maps, and accumulated usage totals across a session replacement on the same thread. Both bridges now send the reset right after the construction's thread/identity — pi in sendThreadSessionResult (start/resume/fork all announce through it) and acp in startAgentSession (all three construction kinds land there). Per-bridge tests assert the reset for each construction path and its after-identity ordering. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…safe Key hygiene, two interlocking halves: - thread-delta.ts now validates every provider-supplied key part (providerItemId, channel, parentRef, streamKey, providerTurnId) through one deltaKeyPartSchema: non-empty and never containing the internal composite-key separator. An empty part or a smuggled separator could join two unrelated key tuples to the same string and silently cross-wire streams; a bad bridge now fails loudly at the protocol parse boundary. message.delta's streamKey previously admitted the empty string. - The assembler's composite keys join with an exported THREAD_DELTA_KEY_SEPARATOR (unit separator, written as a source escape) instead of literal NUL bytes. The raw NULs made delta-assembler.ts register as binary to git, which broke consumer greps; collision safety is preserved because the schema half rejects the separator from every part. Plugin-sdk bundled types rebuilt: unchanged (refinements do not alter the type surface). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A batch opening with session.reset still ran the trailing-edge pending progress flush first, so a snapshot suppressed under the throttle in the dying session could be emitted just before the reset dropped the thread's assembly state — progress from a replaced session leaking into the replacement's timeline. The reset now drops suppressed snapshots: a batch whose first delta is session.reset skips the pre-batch flush (deltas preceding a mid-batch reset still belong to the old session and keep flushing). Unit test proves the defect first. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A delta whose key carried a parentRef for a parent item the assembler had not yet seen fell back to the raw provider parent id on the emitted event's parentToolCallId — an id the timeline could never correlate, since the parent's own open would mint a different bb id. The assembler now mints the parent's bb id at first reference and registers the mapping, so the parent's later open/close lands under the same id. This is the faithful translation of the old per-bridge behavior: their parent ids were deterministic functions of the provider id (raw for pi/acp, prefix-stamped for codex), so parent references always resolved to the id the parent item itself carried regardless of arrival order. The pi and claude suites that pinned the raw-passthrough fallback are updated to pin the consistency instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The eviction guard pinned threads with an open turn or open items but not threads whose only live state was a queued input.accepted (input consumed before the provider opened its turn). Under LRU pressure such a thread could be evicted, dropping the acceptance — the eventual turn.open would emit no turn/input/accepted and the terminal-turn invariant would strand. Unit test proves the drop first. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The adapter's required post-initialize handshake silently ignored an initialize result that failed schema validation, leaving the bridge running on default capabilities — masking the shape drift that produced the garbage. A malformed result now throws like the version mismatch does, aborting the provider spawn with an error naming the plugin and the validation issues. Tested alongside the version gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The delta-to-event helper the conformance and bridge suites share converted an invalid thread/delta notification into an empty event list, letting a bridge pass its suite while emitting garbage the real adapter would drop. Invalid deltas now throw with the validation issues and the offending params (test-only surface). No suite was exposed by the change — all four bridges plus the echo example stay green — and a guard test pins the throw. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bb-plugin-authoring builtin skill still told bridge authors to emit canonical ThreadEvents as thread/event notifications, a lane that no longer exists in bridge-protocol v2; it now teaches the thread/delta grammar (v2 handshake, session.reset at construction, the delta turn lifecycle, assembler-minted ids). docs/codex-app-server.md linked the removed plugins/provider-codex/src/event-translation.ts; it points at delta-translation.ts now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The narrow-grammar bump raised HOST_DAEMON_PROTOCOL_VERSION but missed the deliberate double-entry pin in contract.test.ts; records the version in the lineage comment per convention. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The narrow-grammar cutover changes the published provider-bridge surface (delta vocabulary in, kit assembly machinery out), and 0.4.8 is already on npm; the version guard correctly refuses to ship a changed package under a published version. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Michael's call: 0.4.9 rather than 0.5.0 for the narrow-grammar surface change. The bump script refuses downgrades, so both synced sites are edited directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ault 100ms) The delta assembler now batches the streamed-text event family (assistant/reasoning/plan deltas and command/fileChange output deltas, including its own snapshot-diff output) per stream within a flush window, using the progress throttle's no-timer trailing-edge discipline: the first delta of a fresh stream emits immediately (time-to-first-token unchanged), buffers flush on the thread's next traffic once the window elapses, on stream close, and before any non-batchable event (the ordering barrier — coalescing never reorders text relative to opens/closes, turn events, errors, or other streams' flushes). An output reset is never absorbed; session.reset flushes buffered text (still valid for the old session) instead of dropping it. Window 0 disables batching. The per-bridge equivalence/conformance/calibration suites pin per-delta translation fidelity, so their assembler constructions (shared bridge-delta-assembly helper and direct harnesses) pass textDeltaFlushMs: 0 explicitly; a dedicated clock-injected suite covers the batching policy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One knob, no per-provider config: AgentRuntimeOptions.textDeltaFlushMs rides through the provider process manager and adapter factory options into createBridgeProtocolAdapter, which passes it to the delta assembler. Left unset, the assembler's production default (100ms) applies; 0 disables batching. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Batching is assembler policy: the protocol doc's assembler section gains the windows, first-delta rule, and ordering barrier; the plan records the design, the session.reset flush-vs-drop choice, the equivalence-suite pinning at window 0, and the measured event reduction (310 -> 92 on a representative chatty turn). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The test landed on main reading item/agentMessage/delta from the deleted thread/event notifications; the bridge behavior it verifies (the MCP server command advertised from the bridge module under the bootstrap) already worked — only the test's assistant-text observer was stale. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
e1a9fc1 to
578b8b2
Compare
Claude's bridge used to smuggle restart-generation identity through item id text (task:<taskId>#<generation>), which thread-view's getBackgroundTaskFamilyId parsed to correlate a restarted task with its earlier generation's metadata. Under the delta assembler's centrally minted item ids (<entropy>-iN) that suffix never reaches the persisted id, so family correlation silently broke for new events. The backgroundTask delta shape now requires familyId (the provider's stable task id), the assembler passes it through onto the canonical domain item (optional there — old persisted events lack it), and the claude bridge populates it from its tracked taskId. thread-view prefers the explicit field, namespaced as family:<id>, and keeps the legacy #N id parse only as the documented fallback for pre-cutover events. No protocol version bump: thread/delta is unreleased on this branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The thread/delta grammar is unreleased on this branch, so dead cells die
before it ships. Verified by grep that no bridge emits any of these:
- message.close's `detach` variant: silent stream release lost to the
auto-detach on tool item.open, which every bridge relies on instead.
- session.ended's `replaced`/`exited` reasons and `error` field: every
emitter (pi bridge, claude delta-translation) sends bare interruption,
and nothing branches on the reason, so the delta is now `{kind:
"session.ended"}` and the assembler always settles as interrupted.
- Fixed the schema doc comment that claimed generic close fields always
win over the terminal shape: the real rule is per-shape (generic wins
for command output/exit code, shape wins for tool result), preserved
byte-for-byte from the codex-vs-pi translator conversions.
Tests that existed solely for the deleted cells are removed; auto-detach
coverage stays.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
## What was wrong Cursor ACP applies its project MCP approval gate to client-supplied session MCP servers. ACP has no client permission round trip for that gate, so Cursor rejected the valid `bb-bridge` stdio config before spawning it. The same config-advertisement path exists before and after #1834, and the #1932 bootstrap fix remains valid; the missing Cursor approval was the separate root cause. ## What changed The ACP bridge now installs the exact bb-owned session MCP fingerprint in the Cursor project approval store before `session/new`, `session/load`, or `session/fork`. It limits the workaround to `cursor-agent` plus the `bb-bridge` config, preserves existing approvals, serializes concurrent updates, and removes approvals that bb installed when the session ends. The MCP child also reports `initialize` back to the bridge, giving host-side diagnostics for both config construction and successful child startup. No server/host-daemon wire contract changed, so `HOST_DAEMON_PROTOCOL_VERSION` does not need a bump. ## How you verified Added fingerprint, approval-file preservation/concurrency, session-lifecycle, and MCP initialize diagnostic regressions. These expose the missing approval before the fix and pass afterward. - `pnpm exec turbo run test --filter=bb-plugin-provider-acp --force` — 175 passed - `pnpm exec turbo run typecheck --filter=bb-plugin-provider-acp` - Isolated manual run against Cursor CLI `2026.06.19-20-24-33-653a7fb`, with approval installed after ACP `initialize` and before `session/new`; Cursor spawned and initialized the MCP server Fixes #2018 > AGENT GENERATED: by GPT-5
Implements plans/narrow-grammar-protocol.md end to end: the Provider Bridge Protocol's timeline lane is now a narrow grammar of parsed semantic deltas (
thread/delta), and one runtime-owned assembler constructs every canonicalThreadEvent. All four bridges are converted; the oldthread/eventlane is deleted; there is exactly one dialect.PROVIDER_BRIDGE_PROTOCOL_VERSION1 → 2.The design in one paragraph
Previously a bridge owed the runtime finished canonical events: it opened turns, minted and scoped item ids, queued accepted input, settled items, and constructed
@bb/domainshapes — four parallel implementations of the same timeline state machine, which is where the recurring turn-lifecycle and id-collision bug classes lived. Now the bridge knows the dialect, the runtime knows the timeline: bridges emit facts (item.open {command, cwd},message.delta,turn.boundary {status},session.ended…) and the assembler — one implementation with an extensive dedicated test suite — owns id minting (entropy-scoped, bidirectional provider↔bb maps), accepted-input correlation, the exactly-one-terminal-state invariant, delta-first synthesis, settle/reopen dedup, pairing/close-echo, text accumulation, snapshot diffing, usage accumulation, and progress throttling. Structural violations of turn lifecycle are no longer possible for a provider to express.What each conversion proved
item.closerule (close always carries the full terminal shape — mid-flight reclassification and close-without-open become one rule); interactionturnIdresolved runtime-side (turnId: nullon the wire).item.textDeltavsitem.outputDeltamaking the started-synthesis exception structural; command-plane reverse id mapping at the adapter seam (steer/interrupt translate bb ids → provider ids via assembler maps).turn.boundarywhile blocking tasks are open) and generic policy (central progress throttling — now every provider's delta-aggregation knob — and an open-items eviction guard). Calibration golden: byte-identical event stream.Every conversion ported its full test suite as equivalence evidence (same fixtures → deltas → real assembler → exact canonical events; ids asserted by shape) and passes the conformance suite end to end.
The deletion
thread/eventingestion, its notification vocabulary, and the bridge kit's assembly machinery (turn-state registry, scoped-item-ids, accepted-user-messages, terminal-turn resolution, item constructors — 1,140 published lines) are gone. The published SDK surface ends at 184 names (192 at branch base, after absorbing the entire delta grammar in between), anddocs/api_to_audit.mdaudit item 1 is resolved: the protocol owns its own timeline vocabulary; the remaining@bb/domainre-exports are command-plane contracts with named consumers. The handshake now actually enforces the protocol version (it previously never checked): a v1 artifact fails startup with a legible "update the provider plugin" error. No third-party bridges exist in the wild; first-party artifacts rebuild with the repo. Nothing crosses the server↔daemon wire —HOST_DAEMON_PROTOCOL_VERSIONuntouched.Verification
OPENAI_API_KEYunset: 4/4 Turbo tasks, 64 direct runtime CLI tests, and 11 server/daemon E2E tests. Subscription-backed Codex, Claude, and Pi plus ACP/OpenCode all ran in the full manual runbook.spawnAgentdelegation, ACP accept-edits writes, archive round trip, and a clean process sweep. Two unrelated findings were reproduced as pre-existing on main: pi/compacton a small session surfaces as a failed turn and puts the thread in error state #1721 and acp-cursor advertises fork support it does not have; forking births an errored thread #1833.session.endednow settles that item before the interrupted turn completes. The runbook log commands were also corrected to use the configured rotated-log directories.Follow-ups (not in this PR)
/compacton a small session surfaces as a failed turn and puts the thread in error state #1721 / acp-cursor advertises fork support it does not have; forking births an errored thread #1833 as tracked.🤖 Generated with Claude Code