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

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
Contributor

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

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
Contributor

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
Contributor

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
Contributor

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
Contributor

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
Contributor

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
Contributor

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
Contributor

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 14 commits August 19, 2026 16:08
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
SawyerHood added a commit that referenced this pull request Aug 21, 2026
)

## 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>
SawyerHood added a commit that referenced this pull request Aug 21, 2026
## 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>
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