Skip to content

Narrow-grammar provider bridge protocol: bridges emit semantic deltas, the runtime assembles the timeline (v2) - #1834

Merged
ymichael merged 51 commits into
mainfrom
narrow-grammar-prototype
Aug 20, 2026
Merged

Narrow-grammar provider bridge protocol: bridges emit semantic deltas, the runtime assembles the timeline (v2)#1834
ymichael merged 51 commits into
mainfrom
narrow-grammar-prototype

Conversation

@ymichael

@ymichael ymichael commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

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 canonical ThreadEvent. All four bridges are converted; the old thread/event lane is deleted; there is exactly one dialect. PROVIDER_BRIDGE_PROTOCOL_VERSION 1 → 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/domain shapes — 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

  • pi (first tenant): the terminal-turn triangle, threshold-compaction last-turn scoping, cumulative bash-output diffing, checkpoint stamping — translator went stateful 1,259 → stateless 898 lines.
  • acp: the uniform item.close rule (close always carries the full terminal shape — mid-flight reclassification and close-without-open become one rule); interaction turnId resolved runtime-side (turnId: null on the wire).
  • codex: vouched provider-turn keys (multiplexed subagent child turns as first-class keyed turns); item.textDelta vs item.outputDelta making the started-synthesis exception structural; command-plane reverse id mapping at the adapter seam (steer/interrupt translate bb ids → provider ids via assembler maps).
  • claude-code (hardest): no per-provider assembler extension was needed. The background-task machine decomposed into bridge-side dialect (snapshot fold, generations, completion-blocking — the bridge withholds turn.boundary while 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/event ingestion, 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), and docs/api_to_audit.md audit item 1 is resolved: the protocol owns its own timeline vocabulary; the remaining @bb/domain re-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_VERSION untouched.

Verification

  • Full forced Turbo gate green during branch-targeted QA: lint (0 errors; 147 existing warnings), build (8/8), and the test graph (60/60 uncached). Coverage included agent-runtime (411), plugin-sdk (104), ACP (162), Codex (167), Claude Code (262), server (1,731), host daemon (583), fake-provider integration (55), and app (2,887; 3 skipped). After deduplicating the independent five-file Make plugin host loading and telemetry tests deterministic #1877 patch, all affected branch-specific suites were rerun on the committed tree: agent-runtime (411), plugin-sdk (104), and Claude Code (262).
  • Real-provider integration green with ambient OPENAI_API_KEY unset: 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.
  • Live QA passed on all four providers: spawn/tool turns, mid-turn steer, interrupt, resume with zero cross-boundary id collisions, forks, Pi manual compaction (49,932 → 30,264 tokens), Claude blocking-subagent turn hold, Codex spawnAgent delegation, ACP accept-edits writes, archive round trip, and a clean process sweep. Two unrelated findings were reproduced as pre-existing on main: pi /compact on 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.
  • The branch-targeted pass found and fixed two bugs with regression coverage: concurrent plugin-SDK builds could temporarily delete live runtime exports, so builds now stage outputs and promote each file atomically; and interrupted Claude streams could leave a partially streamed assistant item open, so session.ended now settles that item before the interrupted turn completes. The runbook log commands were also corrected to use the configured rotated-log directories.
  • The same pass exposed independent host artifact-cache and telemetry-test flakes. Those fixes were deliberately removed from this branch and are owned by Make plugin host loading and telemetry tests deterministic #1877; the two PRs have no overlapping files.

Follow-ups (not in this PR)

🤖 Generated with Claude Code

AGENT GENERATED: by Claude Fable 5

@bb-slop-cop

bb-slop-cop Bot commented Aug 18, 2026

Copy link
Copy Markdown

🚨 SLOP COP 🚨 · review

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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 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({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 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.

@bb-slop-cop bb-slop-cop Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 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:

  1. The host protocol does not change. PROVIDER_BRIDGE_PROTOCOL_VERSION changes to 2, but HOST_DAEMON_PROTOCOL_VERSION remains 132. An old daemon can run a new bridge and ignore thread/delta. Users then get an empty timeline.

  2. The packed-package smoke client still uses version 1. packages/bb-app/scripts/smoke-tarball.mjs still waits for thread/event. The full Turbo smoke test failed after 10 minutes and 56 seconds at the Pi installed-package check.

  3. 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.

  4. 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.

  5. 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.

  6. Pi command output can cause quadratic bridge traffic. Pi sends each full output snapshot through JSON. The runtime computes the suffix only after transport.

  7. 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.md also links to a removed file.

  8. 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:tarball task 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.

@ymichael
ymichael force-pushed the narrow-grammar-prototype branch 5 times, most recently from bd07bed to ba6753b Compare August 19, 2026 22:47
ymichael and others added 16 commits August 19, 2026 16:08
…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>
ymichael and others added 20 commits August 19, 2026 16:08
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>
@ymichael
ymichael force-pushed the narrow-grammar-prototype branch from e1a9fc1 to 578b8b2 Compare August 19, 2026 23:11
ymichael and others added 2 commits August 19, 2026 16:37
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>
@ymichael
ymichael merged commit 6e1af11 into main Aug 20, 2026
12 checks passed
@ymichael
ymichael deleted the narrow-grammar-prototype branch August 20, 2026 00:00
ymichael added a commit that referenced this pull request Aug 20, 2026
## 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
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