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>
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
) ## What was wrong Codex labels each reconnect attempt with a structured `codexErrorInfo` (for example `{ responseStreamDisconnected: { httpStatusCode } }`, `willRetry: true`, failure text in `additionalDetails`), then reports the terminal failure for the same stream error with `codexErrorInfo: "other"` and the failure text moved to `message`. That downgrade is upstream: codex-rs `notify_stream_error` always labels retries `ResponseStreamDisconnected`, while `CodexErr::to_codex_protocol_error` maps `CodexErrorDetails::Stream` to `CodexErrorInfo::Other`. The bridge trusted the terminal value, so the final timeline row lost the `stream-disconnected` category and rendered as a generic **Provider error** (the detail text is longer than the 80-char title budget, so the disconnect cause was only visible after expanding the row). Issue: #1840. The independent report URL (https://get-bb.github.io/reports/issues/1840.html) returns 404; the issue body and #1563 carry the repro. ## What changed - `plugins/provider-codex/src/delta-translation.ts`: the translation state remembers the retry-time `codexErrorInfo` and failure text per codex `threadId\0turnId`. A terminal (`willRetry: false`) error whose `codexErrorInfo` is `other` and whose failure text equals the remembered retry text reuses the retry classification. The context is consumed by the terminal error, dropped on `turn/completed`, and never crosses turns. Unrelated terminal errors and every non-`other` terminal value keep the provider-reported classification. No provider prose is parsed. - `plugins/provider-codex/src/translator.ts`: `thread/closed` also clears the retry context for that codex thread (exported `clearCodexEventTranslationThreadState`). - No wire change between server and host daemon: the `provider/error` event shape is unchanged, only the value of `errorInfo` on this one path. No CLI or doc surface changes. Relation to #1563: that PR (same design, by @ymichael and @brsbl) targets `plugins/provider-codex/src/event-translation.ts`, which #1834 deleted when it moved Codex onto the narrow-grammar delta translator. It no longer merges (`git merge-tree` reports a content conflict in `delta-translation.ts`). This PR ports that design onto `delta-translation.ts`, with a flat `Map` keyed by thread+turn instead of nested maps. ## How you verified Tests added in `plugins/provider-codex/src/translator.test.ts` (`codex terminal retry-error classification`): - carries the retry classification into the degraded terminal error (and the context is consumed, so a repeat stays `other`) - does not relabel an unrelated terminal error after a reconnect - scopes the retry context to the turn and drops it on `turn/completed` - drops the retry context when the codex thread closes Fail-before: with `delta-translation.ts` and `translator.ts` restored from `origin/main`, `pnpm exec turbo run test --filter=bb-plugin-provider-codex --force -- --run src/translator.test.ts` fails the first test: ``` × carries the retry classification into the degraded terminal error AssertionError: expected [ { type: 'provider/error', …(7) } ] to deep equally contain ObjectContaining{…} expected "category": "stream-disconnected", "providerCode": "responseStreamDisconnected" received "category": "unknown", "providerCode": "other" ``` The three negative tests pass on `origin/main` as expected (they pin that the guard does not over-apply). Pass-after, from the committed tree (`git status --porcelain` empty): - `pnpm exec turbo run typecheck test --filter=bb-plugin-provider-codex` → `Tasks: 7 successful, 7 total` (16 test files, 176 tests passed) - `pnpm exec turbo run build` → `Tasks: 18 successful, 18 total` Manual replay of the incident's two events (reconnect with `responseStreamDisconnected`, then terminal `other` with the same `stream disconnected before completion: ...` text) through `createCodexEventTranslator` via `node --conditions=source --import tsx`: the terminal delta now carries `errorInfo: { category: "stream-disconnected", providerCode: "responseStreamDisconnected", httpStatusCode: null }`. ## Rebase Rebased onto `origin/main` after the grammar-v3 bridge stack landed (#2124, #2136, #2153, #2148, #2164). That stack rewrote `delta-translation.ts` (presentation on every item, `injectedToolsByName` on the translation state, delegation items) and `translator.ts` (`clearClosedThreadState` now returns the closes for open delegations), but it did not touch the `error` case, `toProviderErrorInfo`, or `turn/completed`, so the fix maps onto the new code unchanged: - The only textual conflict was in `CodexEventTranslationState` / `createCodexEventTranslationState`, where main added `injectedToolsByName` next to where this PR adds `retryErrorsByTurnKey`. Resolved by keeping both fields. - `clearCodexEventTranslationThreadState` is still called from `clearClosedThreadState` in `translator.ts`, before it returns the delegation closes that main added. - The tests merged cleanly into `translator.test.ts`; they run through the grammar-v3 `createDeltaAssembler` harness on main, and the `provider/error` event shape they assert is unchanged. Re-verified on the new base (`798b720ef`, one commit on top of `origin/main`): - Fail-before: with `delta-translation.ts` and `translator.ts` restored from `origin/main`, `pnpm exec turbo run test --filter=bb-plugin-provider-codex --force -- --run src/translator.test.ts -t "codex terminal retry-error classification"` → `1 failed | 3 passed`; `carries the retry classification into the degraded terminal error` fails with expected `"category": "stream-disconnected", "providerCode": "responseStreamDisconnected"`, received `"category": "unknown", "providerCode": "other"`. The bug is still present on current main. - Pass-after, from the committed tree (`git status --porcelain` empty): `pnpm exec turbo run typecheck test --filter=bb-plugin-provider-codex --force` → `Tasks: 7 successful, 7 total`, `Test Files 18 passed (18)`, `Tests 197 passed (197)`. `pnpm exec turbo run build` → `Tasks: 18 successful, 18 total`. Fixes #1840 > AGENT GENERATED: by Claude Opus 5 ## Independent verification Verified on a fresh checkout of `bb/fix-1840-codex-stream-disconnect` (282f32e, one commit on top of `origin/main`; `git merge-base --is-ancestor origin/main HEAD` true, GitHub reports MERGEABLE). Root cause checked against current upstream sources (not from the PR description): `codex-rs/core/src/session/mod.rs` `notify_stream_error` hard-codes `CodexErrorInfo::ResponseStreamDisconnected` for every retry notification, `codex-rs/core/src/responses_retry.rs` returns the raw `CodexErr` once retries are exhausted, and `codex-rs/protocol/src/error.rs` `to_codex_protocol_error` has no arm for `CodexErrorDetails::Stream` so it hits `_ => CodexErrorInfo::Other`. The app-server maps `EventMsg::StreamError` to `error` with `willRetry: true` plus `additionalDetails`, and `EventMsg::Error` to `willRetry: false` with `additional_details: None`. The PR's correlation (retry `additionalDetails` vs terminal `message`, same thread+turn, `other` only) matches that wire shape exactly. Commands: - `pnpm install --frozen-lockfile --prefer-offline` and `pnpm exec turbo run build` (18/18). - Fail-before: `git checkout origin/main -- plugins/provider-codex/src/delta-translation.ts plugins/provider-codex/src/translator.ts`, then `pnpm exec turbo run test --filter=bb-plugin-provider-codex --force -- --run src/translator.test.ts -t "codex terminal retry-error classification"`: 1 failed, 3 passed. Failing assertion: `carries the retry classification into the degraded terminal error` -> `AssertionError: expected [ { type: 'provider/error', ...(7) } ] to deep equally contain ObjectContaining{...}`, expected `"category": "stream-disconnected", "providerCode": "responseStreamDisconnected", "httpStatusCode": 502`, received `"category": "unknown", "providerCode": "other", "httpStatusCode": null`. - Pass-after: restored the PR sources (`git status --porcelain` empty), `pnpm exec turbo run typecheck test --filter=bb-plugin-provider-codex --force` -> `Tasks: 7 successful, 7 total`, `Test Files 16 passed (16)`, `Tests 176 passed (176)`. - Only `packages/thread-view/src/error-display.ts` consumes the `stream-disconnected` category (title text); no runtime recovery keys on it, so the blast radius is the timeline row title. Repro on the fixed branch: a real Codex stream outage cannot be triggered deterministically here, so I replayed upstream-shaped events through `createCodexEventTranslator` directly (`node --conditions=source --import tsx`): four `Reconnecting... n/5` retries labelled `responseStreamDisconnected` with the failure text in `additionalDetails`, then a terminal `other` with that text as `message` and `additionalDetails: null`. Fixed branch: `{"category":"stream-disconnected","providerCode":"responseStreamDisconnected","httpStatusCode":null}`. Same script with `origin/main` sources: `{"category":"unknown","providerCode":"other"}`. Extra negative replays on the fixed branch all stayed correct: unrelated terminal text after a retry stays `unknown`; a structured terminal value (`responseTooManyFailedAttempts`, 503) is never overridden by the remembered retry; a thread-scoped retry (no `turnId`) does not relabel a turn-scoped terminal; a retry on a different codex thread does not leak across threads. CI: all checks pass (Checks, Package Smoke ubuntu+macos, Tests app-1/2/3, integration, server, packages, version check). Residual risks (minor, not blocking): the correlation needs the terminal `message` to equal the last notified retry's `additionalDetails`; the terminal `CodexErr` is the attempt after the last notified retry, so if the inner reqwest text differs between attempts the row falls back to today's generic label (never to a wrong one). Retry context for a turn whose child dies without `thread/closed` or `turn/completed` lives in the per-session translator until the session is released (a few bytes). The upstream mapping gap in codex-rs remains. > AGENT GENERATED: by Claude Opus 5 ## Independent verification (post-rebase) Re-verified after the rebase onto the grammar-v3 bridge. Checked out `798b720ef` (one commit on top of `cf00cfe06`); `origin/main` had since gained #2120 (provider-literal ratchet), which merges cleanly and excludes `plugins/provider-*`, so it cannot affect this PR (`node scripts/check-provider-literal-ratchet.mjs --base origin/main` -> `ratchet OK: 148 references across 40 core files`). Fix still targets the right code path in the rewritten translator: the v3 stack left the `error` case, `toProviderErrorInfo`, and `turn/completed` unchanged; `clearCodexEventTranslationThreadState` runs inside `clearClosedThreadState` before the delegation closes it now returns; `translateEvent` still routes `error` events through `translateCodexEventToDeltas(event, eventTranslationState)` with the single per-session state. Nothing downstream reclassifies: `@bb/agent-runtime` `shouldRestartCodexThreadAfterEvent` keys only on `rate-limit`/`unauthorized` categories (a retry-time label is always `stream-disconnected`, so restart policy is unchanged) and `packages/thread-view/src/error-display.ts` is the only consumer of the category. Commands (all from the committed tree): - Fail-before on current main source: `git checkout origin/main -- plugins/provider-codex/src/delta-translation.ts plugins/provider-codex/src/translator.ts`, then `pnpm exec vitest run src/translator.test.ts -t "codex terminal retry-error classification"` in `plugins/provider-codex` -> `1 failed | 3 passed`; `carries the retry classification into the degraded terminal error` fails with expected `"category": "stream-disconnected", "providerCode": "responseStreamDisconnected", "httpStatusCode": 502`, received `"category": "unknown", "providerCode": "other", "httpStatusCode": null` (`src/translator.test.ts:1272`). - Pass-after (sources restored, `git status --porcelain` empty): same vitest command -> `4 passed`. `pnpm exec turbo run typecheck test --filter=bb-plugin-provider-codex --force` -> `Tasks: 7 successful, 7 total`, `Test Files 18 passed (18)`, `Tests 197 passed (197)`. - Dependents: `pnpm exec turbo run typecheck test --filter=@bb/provider-parity --filter=@bb/agent-runtime --filter=@bb/provider-bridge-protocol --force` -> `Tasks: 10 successful, 10 total` (parity 43 passed, agent-runtime 439 passed, bridge-protocol 217 passed). The parity suite replays every committed recording, including `recordings/codex/auth-failure`, through the current bridge with zero diffs. - `pnpm exec prettier --check` and `pnpm exec eslint` on the three touched files: clean. Repro on the fixed branch: replayed the issue's event sequence (four `Reconnecting... n/5` retries labelled `responseStreamDisconnected` with the failure text in `additionalDetails`, then terminal `other` with that text as `message`) through `createCodexEventTranslator` via `node --conditions=source --import tsx`: terminal delta is `{"category":"stream-disconnected","providerCode":"responseStreamDisconnected","httpStatusCode":null}`. A terminal-only event with no preceding retry stays `unknown`/`other` (by design). Cross-checked against upstream `codex-rs/protocol/src/error.rs` (`Stream(..)` still falls to `_ => CodexErrorInfo::Other`; terminal `message` is `self.to_string()`, the same text `notify_stream_error` puts in `additional_details`). CI: all 11 check-runs on `798b720ef` succeed (Checks, Package Smoke ubuntu+macos, Tests app-1/2/3, integration, server, packages, version check x2); 2 skipped (node-compat smoke, iOS flows). Residual risk (minor, not blocking): the real recorded `auth-failure` cell shows Codex's failure text can carry per-request `cf-ray`/`request id` values that differ between the last retry and the terminal attempt; there the exact-text guard does not fire and the terminal row stays the generic label exactly as on main (for that 401 case a `stream-disconnected` label would arguably be wrong anyway, and the runtime's 401 restart text pattern still matches). Retry context for errors without a `turnId` is only dropped on `thread/closed`. > AGENT GENERATED: by Claude Opus 5 Co-authored-by: Claude <noreply@anthropic.com>
## What was wrong
A Pi extension can inject a custom message and trigger a turn on its own
(`pi.sendMessage(..., { triggerTurn: true })`; this is how
`@aliou/pi-processes` wakes a thread when a background command
finishes). Pi emits `agent_start`, then `message_start`/`message_end`
with `role: "custom"` for that message. bb's Pi translator had no case
for custom-role boundaries and its visibility metadata rated the role
`unknown`, so both envelopes surfaced as `provider/unhandled`
("Unhandled Pi event" rows in dev builds or with the setting on), and
the message itself was never recorded. The extension-triggered turn
therefore showed an assistant answer with no input in front of it, in
the app and in `bb thread log`. bb also had no grammar for
provider-originated input at all: the narrow-grammar `thread/delta` has
no delta for it, and nothing in `@bb/thread-view` projected a
`userMessage` item.
The second half of the issue (Pi's `agent_end.messages` carrying string
content, which stranded the turn as "Working...") already landed in
#1663.
Issue: #1681. Report: https://get-bb.github.io/reports/issues/1681.html
PR #1682 attacks the same gap but was written against
`event-translation.ts`, which #1834 replaced with
`delta-translation.ts`; it no longer applies to main. This PR implements
the equivalent on the narrow-grammar path and keeps the generic `role:
"custom"` handling the report asked for.
## What changed
- `packages/provider-bridge-protocol/src/thread-delta.ts`: new
`input.provider` delta (`text`, optional `parentRef`) for input the
provider injected without a bb client request. Additive, so no bridge
protocol version change; the G3 grammar guardrail snapshot
(`provider-bridge-grammar.v2.snapshot.json`) gains the new kind.
- `packages/provider-bridge-protocol/src/assembler/delta-assembler.ts`
(moved there from `@bb/agent-runtime` on main): `input.provider` records
an `item/completed` `userMessage` item (assembler-minted id) in the open
turn; with no turn open it is dropped, because Pi appends idle
`attention: context` notes to its own context without running the agent
and there is no bb turn to attach them to.
- `packages/agent-runtime/src/pi/delta-translation.ts`: parses
`message_start`/`message_end` for `role: "custom"` (any `customType`,
string or block-array content). A displayed `message_start` becomes
`input.provider`; `message_end`, hidden messages, and empty text
translate to nothing.
- `packages/agent-runtime/src/pi/visibility.ts`: custom-role boundaries
are `noise`, so the silent cases never reach the unhandled fallback.
- `packages/thread-view/src/user-message-parsing.ts`,
`build-event-projection.ts`: project the `userMessage` item as a
system-initiated accepted steer of its turn. It renders as the existing
"System Message" row in the app (inside the turn's "Worked for" group in
summary mode) and as a `User` row in `bb thread log`. It is a `steer`,
not a `message`, on purpose: the server pages the timeline on `message`
rows backed by stored `client/turn/requested` events
(`timelineSegmentAnchorConditions` in `@bb/db` vs
`isTimelineSegmentAnchorRow` in `timeline-pagination.ts`). With a
`message` row here the latest page silently dropped every earlier turn
and reported `hasOlderRows: false`; I hit this live before switching.
- `HOST_DAEMON_PROTOCOL_VERSION` 151 -> 152 (146 -> 147, then 150 ->
151, before two rebases onto a moving main): the daemon now sends a
`userMessage` item it never emitted before. The shape already existed in
the shared schema, so the bump is for the semantic change and to roll
the fix to enrolled daemons.
- CI guard: this PR no longer carries an `@get-bb/plugin-sdk` version
change. `thread-delta.ts` and the assembler are bundled into the SDK's
published provider-bridge entry points, so the npm version guard
(`check-npm-version-guard.mjs`) needs an unpublished version; `main` has
since moved the SDK to `0.4.13`, which npm has not published (npm latest
is `0.4.12`), so this PR adopts `main`'s version and the guard passes
with no further bump.
Not done, deliberately: the issue's "empty assistant output produces an
explicit warning". Nothing in bb promises that today and #1682's version
never fired on real Pi (it assumed `message_start` before
`agent_start`).
## How you verified
New tests, all fail on `origin/main` source and pass with the fix:
- `packages/agent-runtime/src/pi/delta-translation.test.ts`
- "records a displayed Pi custom message as the input of the turn it
triggered" (real order: `agent_start` -> `message_start` ->
`message_end`). On main: `AssertionError: expected [ Array(1) ] to
deeply equal [ { type: 'item/completed', ... } ]` with a
`provider/unhandled rawType: "sdk/message_start"` received.
- "joins the text blocks of an array-content Pi custom message"
- "drops hidden and idle Pi custom messages without surfacing them as
unhandled". On main: `expected [ Array(1) ] to deeply equal []`.
- `packages/thread-view/test/timeline-cli-rendering.snapshots.test.ts`
"shows provider-injected input as a system-initiated steer of its turn".
On main: `expected [ { initiator: 'user', ... } ] to deeply equal [ {
initiator: 'user', ... }, ...(1) ]` (no provider row projected).
- `apps/server/test/services/threads/timeline-provider-input.test.ts`:
latest page keeps the user's first turn, `returnedSegmentCount: 1`,
`hasOlderRows: false`, provider input nested in turn 2. Fails if the row
is projected as a `message` (first turn dropped: `expected [ ...(2) ] to
deeply equal [ 'user:Reply only with ok.', ...(3) ]`).
Commands, run from the committed tree (`git status --porcelain` empty):
- `pnpm exec turbo run typecheck --filter=@bb/provider-bridge-protocol
--filter=@bb/agent-runtime --filter=@bb/thread-view
--filter=@bb/host-daemon-contract --filter=@bb/host-daemon
--filter=@bb/server --filter=@bb/cli --filter=@bb/app
--filter=bb-plugin-provider-acp --filter=bb-plugin-provider-codex
--filter=bb-plugin-provider-claude-code` -> `Tasks: 15 successful, 15
total`
- `pnpm exec turbo run test --filter=@bb/provider-bridge-protocol
--filter=@bb/agent-runtime --filter=@bb/thread-view
--filter=@bb/host-daemon-contract --filter=bb-plugin-provider-acp
--filter=bb-plugin-provider-codex
--filter=bb-plugin-provider-claude-code` -> `Tasks: 11 successful, 11
total` (agent-runtime 31 files, thread-view 21 files, protocol 10 files)
- `pnpm exec turbo run test --filter=@bb/server` -> 195/196 files pass;
the one failure is `internal-skill-trees.test.ts` expecting file mode
0644 on a umask 0002 machine (pre-existing local-only failure, passes in
CI). `timeline-provider-input.test.ts` passes.
Manual, on my own dev instance with the report's 30-line stand-in
extension (same message shape as pi-processes 0.10.9,
`PI_CODING_AGENT_DIR` pointing at a trust-listed copy of the Pi agent
dir), real Pi session, prompt "Reply only with ok.":
```
16 turn/started turn da6731fd37-t2
17 item/completed turn da6731fd37-t2 userMessage [{"type":"text","text":"<process_event type=\"lifecycle\" kind=\"success\" process_id=\"proc_551c\" name=\"sleep-done\">Process completed ..."}]
18 item/started turn da6731fd37-t2 agentMessage
...
21 item/completed turn da6731fd37-t2 agentMessage "ok"
23 turn/completed turn da6731fd37-t2 status=completed
thread status: idle
```
No `provider/unhandled` events (main produced two for
`sdk/message_start`/`sdk/message_end`). `bb thread log` shows the
process event as a `User` row before the `ok`. The app shows the first
turn, then "Worked for 1s" containing a "System Message" row with the
process event, then `ok`.
Fixes #1681
> AGENT GENERATED: by Claude Opus 5
## Independent verification
Verified by a second agent on a fresh checkout (`git fetch origin
bb/fix-1681-pi-notification-wake && git checkout -b verify-1681-r1
FETCH_HEAD`, head `ce123f38e`; `origin/main` is an ancestor, and main is
still at protocol 146 so the 147 bump does not collide).
Fail-before / pass-after (checked out the `origin/main` versions of the
6 non-test source files, ran the new tests, then restored):
- `packages/agent-runtime` `vitest run src/pi/delta-translation.test.ts
-t "custom message"`: 3 failed on main. First assertion:
`AssertionError: expected [ Array(1) ] to deeply equal [ { type:
'item/completed', …(4) } ]`, received a `provider/unhandled` whose
`rawEvent.params.message.message.role` is `"custom"`. Third: `expected [
Array(1) ] to deeply equal []`. All 3 pass on the PR tree.
- `packages/thread-view` `vitest run
test/timeline-cli-rendering.snapshots.test.ts -t "provider-injected"`:
fails on main with `expected [ { initiator: 'user', …(2) } ] to deeply
equal [ { initiator: 'user', …(2) }, …(1) ]`; passes on the PR tree.
- `apps/server` `vitest run
test/services/threads/timeline-provider-input.test.ts`: fails on main
(`- "user:<process_event …>"` missing from the page); passes on the PR
tree. Also re-checked the guard: patching `parseProviderUserMessage` to
`kind: "message"` makes it fail with the first turn dropped (`expected [
…(2) ] to deeply equal [ 'user:Reply only with ok.', …(3) ]`).
Turbo, from the committed tree:
- `turbo run typecheck` for provider-bridge-protocol, agent-runtime,
thread-view, host-daemon-contract, host-daemon, server, cli, app,
provider-acp, provider-codex, provider-claude-code: `Tasks: 15
successful, 15 total`.
- `turbo run test --force` for provider-bridge-protocol (10 files),
agent-runtime (31), thread-view (21), host-daemon-contract (3),
provider-acp (14), provider-codex (16), provider-claude-code (18):
`Tasks: 11 successful, 11 total`.
- `turbo run test --filter=@bb/server --force`: 195/196 files; the one
failure is `internal-skill-trees.test.ts` (file mode 436 vs 420, local
umask 0002; passes in CI).
Repro on the fixed branch: own dev instance with `PI_CODING_AGENT_DIR`
pointing at a trust-listed copy of the Pi agent dir and the report's
30-line stand-in extension, real Pi session (`thread spawn --provider pi
--permission-mode full --prompt "Reply only with ok."`, thread
`thr_byejnv6paz`). Events: `16 turn/started t2`, `17 item/completed
userMessage <process_event …>`, reasoning, `agentMessage "ok"`, `27
turn/completed status=completed`, thread `status=idle`, zero
`provider/unhandled` (main produced two, for `sdk/message_start` and
`sdk/message_end`). The app shows the first turn intact, then "Worked
for 3s" which expands to a "System Message" row with the process event,
then `ok`. `bb thread log --format verbose` shows the nested `User` row
with the process event and `steer`.
CI at verification time: all ubuntu checks green (Checks, Package Smoke,
Tests app-1/2/3, integration, packages, server); macOS Package Smoke
pending.
Residual risks / notes for the reviewer:
- In the default views the provider input is hidden until expanded: the
app folds it into the collapsed "Worked for" group (existing policy for
system-initiated steers) and `bb thread log` in its default `minimal`
format prints an empty `── Worked for (3s)` header with no input row;
only `--format verbose` shows it. The PR body's "`bb thread log` shows
the process event as a `User` row" holds for verbose only. Making
provider input ungrouped without turning it into a pagination anchor
needs a product decision (a distinct initiator, or teaching the DB
anchor query about `userMessage` items).
- Idle `attention: context` notes (no open turn) are dropped, not
persisted; image blocks in custom messages are ignored.
- Linked PR #1682 is `mergeable=CONFLICTING` and edits
`event-translation.ts`, which #1834 deleted; it cannot land on main.
> AGENT GENERATED: by Claude Opus 5
## Stack
Layer 1/2 of GitHub stack #2217 (`gh stack`), lands first. Base `main`,
`HOST_DAEMON_PROTOCOL_VERSION` 151. Rebased onto main at `75d6fc4d4`
(protocol 150): the only conflicts were `protocol.ts` and
`contract.test.ts`; the rebase also regenerated the bridge grammar
snapshot for the new `input.provider` delta kind. Re-verified on the new
base: the three new test files fail with the six non-test source files
checked out from `origin/main` (`expected [ Array(1) ] to deeply equal [
{ type: 'item/completed', …(4) } ]`, `expected [ 'user:Reply only with
ok.', …(2) ] to deeply equal [ 'user:Reply only with ok.', …(3) ]`) and
pass on this head; `turbo run typecheck test` for host-daemon-contract,
host-daemon, agent-runtime, provider-bridge-protocol, thread-view and
server is green except the known local-only umask `internal-skill-trees`
assertion. #2142 (layer 2/2, protocol 152) is stacked on this branch.
> AGENT GENERATED: by Claude Opus 5
## Independent verification (guards)
Re-verified head `bdf47388f` (rebased onto `origin/main` `27d1017fe`,
`@get-bb/plugin-sdk` 0.4.12) against the previously verified head
`3158939df` on a fresh checkout (`verify-2154-g`).
- `git range-diff 75d6fc4..3158939 origin/main..bdf4738`: the
single commit differs only in
`packages/domain/src/plugin-sdk-version.ts` (`0.4.11` -> `0.4.12`) and
`packages/plugin-sdk/package.json` (`0.4.11` -> `0.4.12`). Every other
hunk is identical. `origin/main` and npm (`npm view @get-bb/plugin-sdk
version`) are both still at 0.4.11 and main is still at protocol 150, so
neither bump collides.
- Guards on this head: `node
packages/plugin-sdk/scripts/check-npm-version-guard.mjs` -> `PASS —
@get-bb/plugin-sdk@0.4.12 is not on npm yet`; `node
scripts/check-provider-literal-ratchet.mjs` -> `OK: 148 references
across 40 core files`.
- Fail-before / pass-after, re-run once on this head (checked out the
`origin/main` copies of the 9 existing non-test source files and deleted
the new `delta-translation.ts`, rebuilt, ran, restored; `git status
--porcelain` empty afterwards):
- `agent-runtime` `delta-translation.test.ts`: cannot load on main
(`Cannot find module './delta-translation.js'`); passes on the PR tree.
- `thread-view` `timeline-cli-rendering.snapshots.test.ts`:
`AssertionError: expected [ { initiator: 'user', …(2) } ] to deeply
equal [ { initiator: 'user', …(2) }, …(1) ]` on main; 50/50 pass on the
PR tree.
- `server` `timeline-provider-input.test.ts`: `AssertionError: expected
[ 'user:Reply only with ok.', …(2) ] to deeply equal [ 'user:Reply only
with ok.', …(3) ]` on main; passes on the PR tree.
- `host-daemon-contract` `contract.test.ts`: `expected 150 to be 151` on
main; 52/52 pass on the PR tree.
- `turbo run typecheck test --force` for agent-runtime, thread-view,
host-daemon-contract, provider-bridge-protocol, domain,
@get-bb/plugin-sdk: `Tasks: 17 successful, 17 total` (442 + 391 + 52 +
218 + 150 + 127 tests). `turbo run typecheck --filter=@bb/server`:
`Tasks: 4 successful, 4 total`.
- CI for `bdf47388f` (run 32508210319): Checks, Package Smoke (ubuntu +
macOS), Tests app-1/2/3, integration, packages, server all pass;
`mergeable=MERGEABLE`, `mergeStateStatus=CLEAN` against `main`.
- The live Pi repro was not re-run in this pass: the diff against the
previously verified head (where it was run with a real Pi extension
turn) is the two version strings above, which do not reach the runtime
path.
## Rebase (2026-08-21, second)
Rebased onto `main` at `d41d1abee`. Two collisions, both from `main`
moving under the PR:
- `main` took protocol **151** (#2242, the auto/steer turn-target
re-resolution), so this PR's change is renumbered **151 -> 152**. Its
comment block now sits above main's 151 block, and the lockstep
assertion in `contract.test.ts` moves to `toBe(152)`. No other file
hardcodes the constant; every other consumer reads it symbolically.
- `main` moved the SDK to `0.4.13`, so `plugin-sdk-version.ts` and
`plugin-sdk/package.json` resolve to main's values and drop out of this
PR's diff.
Re-verified on the new base: `check-npm-version-guard.mjs` -> `PASS -
@get-bb/plugin-sdk@0.4.13 is not on npm yet`. `turbo run typecheck` for
server, agent-runtime, host-daemon-contract, provider-bridge-protocol,
thread-view: `Tasks: 8 successful, 8 total`. `turbo run test` for the
same set: host-daemon-contract 3/3 files, provider-bridge-protocol
16/16, thread-view 23/23, agent-runtime 31/31, server 200 passed / 1
skipped with the single known local-only failure `internal-skill-trees`
(`mode 420` vs `436`, i.e. 0644 vs 0664 under this machine's umask
0002); it passes in CI and this PR does not touch skill trees.
**#2142 (layer 2/2) is renumbered to protocol 153 and rebased on this
head.**
> AGENT GENERATED: by Claude Opus 5
Co-authored-by: Claude <noreply@anthropic.com>
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