Skip to content

Add provider plugin v3 contract: vocabulary, presentation, extensions, interaction split - #2124

Merged
SawyerHood merged 14 commits into
mainfrom
bb/provider-contract
Aug 21, 2026
Merged

SawyerHood merged 14 commits into
mainfrom
bb/provider-contract

Conversation

@SawyerHood

@SawyerHood SawyerHood commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

What was wrong

The provider-plugin migration (docs/provider-plugin-api.md, design thread thr_qs25ef3tq2) has no shared vocabulary yet. Every later workstream — the generic assembler (WS1a), the per-bridge migrations (WS1b), the registry and strings (WS2a/2b), projection and renderers (WS3), runtime recovery (WS4), the interaction split (WS5) — needs the same types: the v3 delta grammar, presentation, extension kinds, the new core item kinds, the handshake range, typed recovery, and the approval/request split. Without one contract PR they each invent their own and the stacks conflict.

What changed

Types and schemas, plus the two narrow runtime pieces review asked for (presentation carry-through, grammar-range gate). Everything is ADDITIVE and lives beside what exists (A1 additive-then-delete: v3 is accepted next to v2). No v3 shape is assembled yet; no existing path is deleted.

Delta grammar v3packages/provider-bridge-protocol/src/thread-delta.ts

  • New item shapes on deltaItemShapeSchema: fileRead { path, cmd? }, search { mode: content|path|list, query, path?, cmd? }, delegation { childRef, label, background, summary? }, planSteps { steps, explanation? }, and extension { kind: "<pluginId>/<name>", payload } (its lifecycle delta must carry presentation). Each is documented with the corpus motivation (Claude Read 7,568 calls → fileRead; codex spawnAgent/wait + Claude Agent + local_agent background tasks → delegation; codex update_plan in 295 threads + Claude TaskCreate/TaskUpdateplanSteps).
  • Optional presentation on item.open and item.close (deltaPresentationSchema = the domain's threadEventItemPresentationSchema: label {pending, completed}, icon {glyph} (host glyphs only — a plugin-relative asset path cannot outlive the plugin; a durable content-addressed form is WS3's, recorded as a G10 gap), title?, detail? (≤280), suppress?, tint? {light, dark}). Presentation lives in ONE place — the lifecycle delta — and is REQUIRED there when the shape is extension. The tool shape's doc names it as the escape hatch for generic tools. The assembler persists it: the open's value on item/started; on settlement the close's value wins and the open's survives when the close carries none (close-echo). Items that never carried one gain no key, so v2 streams are byte-identical.
  • item.progress.snapshot widened to backgroundTask | delegation.
  • New thread-scoped extension.state { extensionKind, payload } delta kind. Only the namespace shape is validated; the payload is opaque with a TODO(WS1a) pointing at server ingest validation.
  • provider/recovery is a bridge notification (notifications.ts), not a delta: it is a runtime signal the way session/replaced and provider/raw are, never persisted. { threadId?, kind: sessionArchived|authRequired|restartRecommended|staleTurn|rateLimited, message, retryable }. No consumer yet (WS4).

Handshakehandshake.ts: grammarVersions: [min, max] in BOTH directions — the runtime states the range its assembler speaks in the initialize params (ASSEMBLER_GRAMMAR_VERSIONS, [2, 2] until WS1a implements the v3 shapes), the bridge states its own in the result (default [2, 2]: a silent side speaks exactly the version it negotiated), negotiateGrammarVersion picks the highest common version, and a disjoint range fails startup with the same legible error a wrong protocolVersion does. steerMode: "inject" | "queue" (default "queue": the conservative reading, consistent with this handshake's "absence is a definite no"; nothing branches on it yet, and every first-party bridge now declares its real mode — inject for pi/claude/codex, queue for ACP v1's cancel-then-prompt). Nothing removed.

Domainpackages/domain/src/provider-event.ts, item-presentation.ts (new), provider-extension-kind.ts (new)

  • Canonical items fileRead, search, delegation, planSteps, extension; optional presentation on every existing provider-produced variant (userMessage excluded: bb authors it).
  • item/delegation/progress and item/delegation/completed events, thread-scoped exactly as item/backgroundTask/* (rationale recorded in thread-event-scope.ts). Wired through the exhaustive switches: thread-event-scope.ts, packages/db/src/stored-event-item-fields.ts, apps/server/src/internal/events.ts, plus packages/thread-view/src/event-decode.ts and the grammar checker, which were also exhaustive.
  • CORE_ITEM_KINDS + CoreItemKind with a type-level exhaustiveness check against threadEventItemSchema (G4).
  • provider-types.ts: optional strings, serviceTiers/reasoningLevels ({id,label,description?}[]), extensionKinds on providerInfoSchema; ProviderRecoveryKind. ProviderInfo construction is unchanged.
  • pending-interactions.ts: tool_use { itemId, tool, presentation } approval subject added to pendingInteractionApprovalSubjectSchema; new interactionRequestPayloadSchema = user_question | plan_review | "<pluginId>/<name>" beside the untouched pendingInteractionPayloadSchema. No producers rewired.

Plugin SDKPluginProviderDeclaration gains experimental_strings, experimental_serviceTiers, experimental_reasoningLevels, experimental_extensionKinds; validatePluginProviderDeclaration validates, freezes and carries them (not silently dropped; not projected — WS2a). Audit entry in docs/api_to_audit.md. @get-bb/plugin-sdk/provider-bridge exports the v3 shapes/types, presentation, handshake and recovery schemas. docs/provider-plugin-api.md lands here byte-identical to #2119 so the G10 test has its subject; if #2119 merges first the rebase is a no-op.

Mobileapps/mobile/src/screens/thread/timeline/item-kind-map.ts: MOBILE_ITEM_KIND_MAP satisfies Record<CoreItemKind | "extension", …>, so a new domain kind without a mobile decision fails to typecheck. New kinds fall through to the existing registry fallback until WS3.

Guardrail seeds

  • G2 provider-contract-purity.test.ts + committed provider-contract-purity.allowlist.json: no key on the provider-agnostic contract names a provider (segment match, so pi does not trip on capabilities). The only allowlisted offender is runtimeThreadExecutionOptionsSchema.claudeCodePermissionMode (WS2b). A new matching key or an unused entry fails.
  • G3 grammar-version.test.ts + provider-bridge-grammar.v2.snapshot.json: a structural snapshot (every delta kind/field, item shape/field, presentation, capabilities, method tables) paired with PROVIDER_BRIDGE_PROTOCOL_VERSION, which stays at 2 — every v3 addition is a new union member or an optional field, which version.ts defines as non-bumping; the v2-deletion workstream bumps.
  • G4 type-level CORE_ITEM_KINDS exhaustiveness + the mobile kind map.
  • G10 packages/plugin-sdk/src/__tests__/provider-plugin-doc.test.ts: extracts every ```ts block from the doc and maps each field onto the real contract (zod keys at runtime, interface keys via satisfies) or an explicit `{ gap: "WS…" }`; a gap that lands fails, and a doc edit fails until the map is updated. Full compilation of the blocks is left as a TODO for stabilization because the doc's blocks are target-state pseudo-code (`bb.providers.register`, `app.slots.timelineRenderer`, `TimelineRow { kind, payload, presentation }` do not exist yet). No new dependency was added.

"Unsupported until WS" stubs (for the coordinator to track; every one is an explicit case, never a silent default):

  • WS1a — packages/agent-runtime/src/delta-assembler.ts: UnsupportedDeltaShapeError thrown for every v3 shape in shapeMatchesItem (L717), buildOpenedItem (L874), buildClosedItemFromShape (L1053), the delegation item.progress snapshot (L1406), and the extension.state delta (L2028).
  • WS5 — throws: apps/server/src/services/interactions/pending-interaction-timeline.ts L447 and L507; plugins/provider-codex/src/interactive-requests.ts L266; plugins/provider-claude-code/src/interactions.ts L300; packages/agent-runtime/src/test/runtime-integration-harness.ts L740.
  • WS5 — declarative-base rendering of tool_use from its presentation (explicit case "tool_use", marked "Declarative base only"): packages/core-ui/src/pending-interaction-formatting.ts L190, packages/core-ui/src/pending-interaction-presentation.ts L54, apps/app/.../ThreadPendingInteractionBanner.tsx L400, apps/server/src/internal/interactive-requests.ts L45, apps/mobile/src/data/interactions/approval-presentation.ts L81, apps/cli/src/commands/thread/interactions.ts L137 and L258.
  • WS3 — apps/mobile/src/screens/thread/timeline/item-kind-map.ts: fileRead, search, planSteps, delegation, extension are { fallback: "WS3 …" }.

HOST_DAEMON_PROTOCOL_VERSION is bumped to 147. The daemon wire carries threadEventSchema and pendingInteractionCreateSchema, and this PR widens both: new union members (item kinds, item/delegation/* event types, the tool_use subject) and an optional field (presentation). An older daemon never emits any of them and nothing in this version produces them, so compatibility is believed preserved in both directions — but the repository rule is to bump rather than ship a widened wire on an untested assumption, and the mismatch moves enrolled machines onto a daemon whose runtime also negotiates the delta grammar range. PROVIDER_BRIDGE_PROTOCOL_VERSION stays at 2 per version.ts ("Additive changes … do NOT bump the version"); the grammar itself is negotiated through grammarVersions. The daemon contract's optional-field allowlist (packages/host-daemon-contract/test/contract.test.ts) gained the four tool_use presentation fields with their omission semantics.

Not done here, by design: no assembly of the v3 shapes, no projection, no ProviderInfo projection from the new declaration fields, no producer of tool_use/plan_review/extension requests, no event-pruning trigger for item/delegation/progress (WS1a adds it with the producer). The only runtime behavior added is the narrow presentation carry-through and the grammar-range gate above, both raised by review.

How you verified

  • pnpm exec turbo run typecheck74/74 packages green (covers provider-bridge-protocol, domain, server-contract, plugin-sdk, agent-runtime, server, app, mobile, host-daemon-contract, cli, core-ui, thread-view, db, and the three provider plugins).
  • pnpm exec turbo run test --force piped to a file for every package with new or affected tests, all green:
    @bb/domain 148 · @bb/provider-bridge-protocol 114 · @get-bb/plugin-sdk 126 · @bb/agent-runtime 427 · @bb/host-daemon-contract 52 · @bb/server-contract 58 · @bb/core-ui 17 · @bb/thread-view 379 · @bb/db 406 · @bb/mobile 833 · @bb/cli 453 · bb-plugin-provider-codex 172 · bb-plugin-provider-claude-code 263 · bb-plugin-provider-acp 181. CI runs the server and app suites.
  • New tests (fail before, pass after): packages/domain/test/provider-event-v3-items.test.ts, packages/domain/test/interaction-split.test.ts, packages/provider-bridge-protocol/src/thread-delta-v3.test.ts, packages/provider-bridge-protocol/src/contract-tests/{provider-contract-purity,grammar-version}.test.ts, packages/plugin-sdk/src/__tests__/{provider-plugin-doc,provider-declaration-v3}.test.ts, apps/mobile/src/screens/thread/timeline/item-kind-map.test.ts.
  • Existing suites are untouched-green: the assembler's exhaustive switches gained throwing v3 cases, and presentation is attached only when a delta carries one, so every existing v2 stream assembles byte-for-byte as before (new assembler tests cover open, close-echo, close-wins, dual-settle, and the no-key v2 path; new adapter tests cover the grammar-range gate).

Review round (SlopCop, 5 findings, all addressed in commits acd42256ed9bf1): presentation persisted by the assembler; two-way grammar negotiation with a startup gate; HOST_DAEMON_PROTOCOL_VERSION → 147; one presentation location (required on the delta for extension shapes); persisted icons glyph-only with the asset form recorded as a WS3 gap.

Rebased over #2140 (9dff42407, "Remove dead code and simplify module surfaces"). Conflicts in provider-event.ts, pending-interactions.ts, notifications.ts resolved by the rule "restore export only on symbols another package imports; otherwise accept the de-export; keep every v3 member". Restored export on 0 symbols: everything the v3 grammar, bridge kit and SDK surface import cross-package (threadEventPlanStepSchema, providerRateLimitStateSchema, threadEventItemStatusSchema, …) was still exported on main; the only breakage was this PR's own same-package test importing two now-private approval schemas, rewritten to parse through the public pendingInteractionPayloadSchema. The new itemPresentationField, the tool_use subject and the request family sit beside #2140's private threadEventWebSearchItemSchema, pendingInteractionApprovalSubjectSchema and AnyPendingInteractionPayload; #2140's removal of the unused ProviderRawNotification type is kept. Verified after the rebase: typecheck green for domain, provider-bridge-protocol, plugin-sdk, agent-runtime, server, thread-view, core-ui, mobile, app, cli, host-daemon-contract and the three provider plugins; tests green for domain 146, provider-bridge-protocol 114, plugin-sdk 126, agent-runtime 423, host-daemon-contract 52, core-ui 17.

@get-bb/plugin-sdk is bumped 0.4.10 → 0.4.11 (top commit, via scripts/bump-plugin-sdk.mjs --patch, both version files together): 0.4.10 is published, and this PR widens the SDK's public surface (provider-bridge.ts v3 exports, backend-contract.ts declaration fields), so the npm version guard requires a new version. check-npm-version-guard.mjs passes locally.

Part of the provider-plugin migration; no single issue. Target doc: #2119.

AGENT GENERATED: by Claude Opus 5

@bb-slop-cop

bb-slop-cop Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

🚨 SLOP COP 🚨 · review

I am the Slop Cop. I am reviewing this pull request for security, code quality, architecture, performance, and product behavior.

@SawyerHood

Copy link
Copy Markdown
Collaborator Author

Coordinator review — APPROVE (pending CI + SlopCop green).

Reviewed the full diff against the migration gate:

  • Additive-only (A1): +3750/−18; the 18 deletions are import/comment tweaks. v3 shapes are accepted beside v2; presentation is optional on item.open/item.close in v3 (required only after the v2-deletion layer); extension shapes carry mandatory presentation. Confirmed.
  • Design fidelity: deltaPresentation reuses threadEventItemPresentationSchema (one vocabulary); search carries the content/path/list mode; delegation shares backgroundTask's thread-scope for background delegations; provider/recovery is correctly a runtime notification, not a timeline delta; grammarVersions defaults to [2,2] and steerMode to queue (conservative, behavior-preserving). TODO(WS1a) markers sit exactly where the assembler/ingest wiring lands.
  • Exhaustive switches: handled in thread-event-scope.ts, stored-event-item-fields.ts, and the delta-assembler shape switches (explicit "unsupported until WS1a" throws, not silent defaults). events.ts:295 is a toolCall-specific statusLabels gate, not an item-kind switch, so it correctly needs no case.
  • Zero-privilege / G1: no new provider-id literal in core (the only "codex"/"pi"/"claude-code" additions are the doc example and the purity test's own scan list).
  • Guardrails: G2 (purity + allowlist, claudeCodePermissionMode flagged for WS2b), G3 (grammar-version snapshot), G4 (presentation coverage), G10 (doc-type sync) all present.
  • Verified locally: @bb/domain + @bb/provider-bridge-protocol tests green; typecheck green across all 15 touched/downstream packages. HOST_DAEMON_PROTOCOL_VERSION correctly unchanged (only optional additive fields).

The doc is identical to the closed #2119; folding it in here with its compile guard is the right call. Merge once macOS smoke + SlopCop finish; WS1a and WS2a fire off this immediately.

AGENT GENERATED: by Claude Opus 4.8

* while v2 deltas are accepted; `extension` shapes carry their own
* mandatory presentation inside the shape.
*/
presentation: deltaPresentationSchema.optional(),

@bb-slop-cop bb-slop-cop Bot Aug 21, 2026

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 — High: The runtime drops each accepted presentation.

item.open and item.close accept presentation, but the assembler does not add it to the canonical item.

A valid v3 delta silently loses its labels, icon, title, detail, suppression, and tint.

The documented close echo also does not occur.

Persist the open and close values. Use the open value when the close value is absent. Add assembler tests for both paths.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in fc88906. The assembler now persists presentation: the open's value rides item/started; on settlement the close's value wins and the open's survives when the close carries none (the documented close-echo). Each settled shape keeps its own on a dual-settle. Items that never carried one gain no key, so v2 streams are byte-identical. Tests: delta-assembler.test.ts (open, close-echo, close-wins, dual-settle, no-key v2 path).

AGENT GENERATED: by Claude Opus 5

* it never claimed. A v3-capable bridge reports `[2, 3]` (or `[3, 3]`
* once the v2 paths are deleted).
*/
grammarVersions: bridgeGrammarVersionsSchema.default([

@bb-slop-cop bb-slop-cop Bot Aug 21, 2026

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 — High: The grammar range does not negotiate a grammar.

Only the bridge reports a range. The runtime does not report its range, select a version, or check for overlap.

An old v2 runtime can accept the bridge. It then rejects a new member on the existing thread/delta method.

Implement two-way grammar negotiation before v3 use. Otherwise, increment the provider bridge protocol version to 3.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 0816b4c. The runtime now states the range its assembler speaks in the initialize params (grammarVersions, default [2, 2] for runtimes that predate the field), negotiateGrammarVersion picks the highest common version, and a bridge whose range shares nothing with the assembler's fails startup with the same legible error a wrong protocolVersion does. An old v2 runtime sends no range, so a [2, 3] bridge reads it as [2, 2] and emits v2 only. The protocol version stays at 2 because the grammar is now negotiated rather than implied. Tests: bridge-protocol-adapter.test.ts, thread-delta-v3.test.ts.

AGENT GENERATED: by Claude Opus 5

* beside v3; absent on every row persisted before the field existed.
*/
const itemPresentationField = {
presentation: threadEventItemPresentationSchema.optional(),

@bb-slop-cop bb-slop-cop Bot Aug 21, 2026

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 — High: The host daemon protocol version must increase.

This change adds optional fields and union members to data that crosses the server and host daemon boundary.

The protocol remains at version 146. The repository contract requires a version increase for these wire changes.

Increment HOST_DAEMON_PROTOCOL_VERSION to 147. Update the related contract test.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 6ed9bf1: HOST_DAEMON_PROTOCOL_VERSION is 147 with the lineage note and the contract test updated. The wire widened additively, but the repository rule is to bump rather than ship a widened wire on an untested compatibility assumption.

AGENT GENERATED: by Claude Opus 5

type: z.literal("extension"),
kind: extensionKindSchema,
payload: jsonValueSchema,
presentation: deltaPresentationSchema,

@bb-slop-cop bb-slop-cop Bot Aug 21, 2026

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 — Medium: An extension item has two presentation sources.

The extension shape has a required presentation. The outer open or close delta can also contain a different value.

The contract gives no rule for these conflicts. Keep the value in one location.

If both locations must remain, define precedence and reject different values.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in acd4225. Presentation now lives in one place — the item.open/item.close delta — and the extension shape carries none of its own. The delta schema requires presentation when the shape is extension (rejected with a presentation path issue otherwise). The G3 grammar snapshot is regenerated for that field.

AGENT GENERATED: by Claude Opus 5

*/
export const threadEventItemPresentationIconSchema = z.union([
z.object({ glyph: z.string().min(1) }),
z.object({ asset: z.string().min(1) }),

@bb-slop-cop bb-slop-cop Bot Aug 21, 2026

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 — Medium: A stored asset path cannot meet the uninstall guarantee.

The stored value has no plugin identity, version, content hash, or durable asset copy.

An old row can lose its icon after a plugin removal or asset replacement.

Store a durable content-addressed asset reference. Otherwise, permit only stable host glyphs in stored presentation data.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in acd4225. Persisted presentation icons are host glyphs only ({ glyph }); the asset form is removed from the schema so a stored path can never outlive its plugin. A durable, content-addressed asset icon is WS3's to add, and the G10 doc-sync test records the doc's asset as that gap so it cannot land silently.

AGENT GENERATED: by Claude Opus 5

@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: This pull request defines one common language for provider plugins.

It standardizes timeline items, labels, custom extensions, recovery hints, and user requests. Most changes add contracts and tests.

I found five issues. Three issues can cause data loss or version conflicts. Two issues make the public presentation contract unclear or unstable.

Findings

  1. High — The runtime drops presentation data. The schema accepts presentation on open and close deltas. The assembler does not persist it. The close echo also fails.
  2. High — Grammar version 3 has no real negotiation. An old version-2 runtime can accept the bridge, then reject a new thread/delta member.
  3. High — The host daemon protocol stays at version 146. The change adds optional fields and union members across that wire boundary.
  4. Medium — Extension items have two presentation sources. The extension shape and the outer lifecycle delta can disagree.
  5. Medium — Stored asset paths are not durable. A plugin removal or asset change can break old timeline icons.

I left inline comments with the exact locations and minimum fixes.

Security, performance, and architecture

I found no active security defect in the current v2 path. The new unsupported item and interaction paths fail closed today.

I found no normal hot-path performance regression. The new cases add constant-time schema and switch work.

The architecture scan found no useful behavior duplication. However, the extension presentation value exists in two contract locations.

The domain and SDK also repeat some vocabulary validation. A later shared contract generator could reduce that maintenance cost.

Checks

  • All 74 Turbo type-check tasks passed.
  • All focused tests passed for the domain, bridge protocol, plugin SDK, runtime, host contract, thread view, and core UI.
  • The live dev app loaded through Doobie. The new-thread flow and provider picker worked.
  • git diff --check passed.

I reviewed the requested commit 37b92eda2f2be85810e046e3f94563bc3d73fb4f.

SawyerHood added a commit that referenced this pull request Aug 21, 2026
Review findings on #2124:

- An extension shape carried its own presentation beside the lifecycle
  delta's, with no precedence rule. Presentation now lives only on
  item.open/item.close; the delta schema requires it when the shape is
  `extension`, and the shape carries none.
- A persisted `{ asset: "./icons/x.svg" }` names a plugin file that is gone
  after uninstall and may change after upgrade, so it could not keep the
  renders-after-the-plugin-is-gone promise. Persisted icons are host glyphs
  only; a durable content-addressed asset form is WS3's (G10 records the
  doc's `asset` as that gap).

The G3 grammar snapshot is regenerated for the extension shape; snapshot
files are test-owned, so prettier now ignores them.

Co-Authored-By: Claude <noreply@anthropic.com>
@SawyerHood

Copy link
Copy Markdown
Collaborator Author

Coordinator re-review of the fix commits (SlopCop now disabled — my review is the gate).

Reviewed acd4225 / fc88906 / 0816b4c / 6ed9bf1:

  • HOST_DAEMON_PROTOCOL_VERSION → 147: correct, and it supersedes my earlier note. I said the bump was unnecessary because the fields are optional/additive. That was the lenient reading; AGENTS.md is explicit that a widened server↔daemon wire must bump unless compatibility was deliberately preserved and tested — which it wasn't. The event/interaction wire did widen (new item kinds, item/delegation/*, tool_use, optional presentation), so the conservative bump is the rule. Agreed.
  • Presentation persistence (fc88906) is correctly scoped: a field passthrough onto the canonical item (open value on item/started, close-wins-with-echo on settle, per-shape on dual-settle). Items that never carry one gain no key, so v2 streams stay byte-identical — exactly what the parity oracle needs. The UnsupportedDeltaShapeError throws for fileRead/search/delegation/planSteps construction remain, so the contract-vs-WS1a boundary is clean.
  • Grammar negotiation both directions (0816b4c): initialize now exchanges the grammarVersions range; runtime picks the max both speak. Matches §2.

No behavior change to v2 today. Approving pending CI green on the fix head. WS1a/WS2a are stacked on this branch and will rebase onto the new head. Note for WS1a: presentation-persistence plumbing is already done here; your job is the v3 item construction the throws guard.

AGENT GENERATED: by Claude Opus 4.8

SawyerHood and others added 13 commits August 21, 2026 06:22
…eraction split

Add the persisted vocabulary the provider-plugin target state needs, all
additive beside the existing shapes:

- threadEventItemPresentationSchema (label, icon, title, detail<=280,
  suppress, tint), optional on every provider-produced item variant.
- New item variants fileRead, search (mode content|path|list), delegation,
  planSteps, and the namespaced extension item ("<pluginId>/<name>",
  opaque JSON payload, mandatory presentation).
- item/delegation/progress and item/delegation/completed events, thread-
  scoped exactly like item/backgroundTask/* so background delegations can
  outlive their turn.
- CORE_ITEM_KINDS with a type-level exhaustiveness check (guardrail G4).
- ProviderInfo gains optional strings, serviceTiers, reasoningLevels and
  extensionKinds; ProviderRecoveryKind is exported.
- tool_use approval subject and the open interactionRequestPayloadSchema
  family (user_question, plan_review, "<pluginId>/<name>") beside the
  untouched pendingInteractionPayloadSchema.

Co-Authored-By: Claude <noreply@anthropic.com>
The scope policy, the stored item-field derivation, the server's provider
identifier resolution, the thread-view decoder and the streaming grammar
checker all enumerate every ThreadEvent type. Add the two delegation events
the same way backgroundTask's are, so the build stays exhaustive. No
producer emits them yet (WS1a).

Co-Authored-By: Claude <noreply@anthropic.com>
- deltaItemShapeSchema gains fileRead, search, delegation, planSteps and
  the namespaced extension shape; item.open/item.close carry an optional
  presentation; item.progress snapshots accept a delegation beside a
  background task; extension.state carries plugin-declared thread state.
- bridgeCapabilitiesSchema gains grammarVersions (default [2, 2]) and
  steerMode (default "queue"), both explicit on parse.
- provider/recovery bridge notification with the typed recovery kinds.
- Guardrail G2: contract purity test with a committed allowlist whose only
  entry is claudeCodePermissionMode (removed by WS2b).
- Guardrail G3: a structural grammar snapshot paired with
  PROVIDER_BRIDGE_PROTOCOL_VERSION, which stays at 2 because every v3
  addition is optional or a new union member (see version.ts).

Co-Authored-By: Claude <noreply@anthropic.com>
…range and steer mode

The delta assembler's three shape switches and its progress-snapshot and
extension.state handlers throw UnsupportedDeltaShapeError for every v3
shape instead of falling into a silent default: the protocol accepts them,
WS1a (generic assembler) implements them, and no shipped bridge emits them,
so existing streams are unchanged.

Every first-party bridge's typed InitializeResult now states
grammarVersions [2, 2] and its steer mode (inject for pi, claude and codex;
queue for ACP v1's cancel-then-prompt).

Co-Authored-By: Claude <noreply@anthropic.com>
No producer raises tool_use until WS5 (interactions). Runtime, server and
bridge sites throw an explicit "not produced until WS5" error; presentation
sites (core-ui, app banner, mobile, CLI) render the declarative base from
the subject's presentation so the switches stay exhaustive without a silent
default.

Co-Authored-By: Claude <noreply@anthropic.com>
…ync guardrail

- PluginProviderDeclaration gains experimental_strings,
  experimental_serviceTiers, experimental_reasoningLevels and
  experimental_extensionKinds; validatePluginProviderDeclaration checks,
  freezes and carries them (WS2a projects them). Audit entry added to
  docs/api_to_audit.md.
- @get-bb/plugin-sdk/provider-bridge exports the v3 shapes, presentation,
  handshake and recovery schemas and types.
- docs/provider-plugin-api.md lands (byte-identical to PR #2119) with
  guardrail G10: every ts block is parsed and each field mapped onto the
  real contract or an explicit WS-owned gap; gaps that land fail the test.

Co-Authored-By: Claude <noreply@anthropic.com>
MOBILE_ITEM_KIND_MAP satisfies Record<CoreItemKind | "extension", …>, so a
new domain item kind without a mobile rendering decision fails to
typecheck. New v3 kinds fall through to the registry fallback until WS3
builds their declarative-base renderers.

Co-Authored-By: Claude <noreply@anthropic.com>
…elds

The daemon wire carries the interaction payload, so the tool_use subject's
presentation (title, detail, suppress, tint) shows up on the optional-field
allowlist. Each omission has a meaning (no headline, no summary, render
normally, neutral tint), recorded as the allowlist requires. No wire
version bump: an older daemon never emits tool_use, and nothing changed for
the subjects it does emit.

Co-Authored-By: Claude <noreply@anthropic.com>
…in the bridge protocol doc

Co-Authored-By: Claude <noreply@anthropic.com>
Review findings on #2124:

- An extension shape carried its own presentation beside the lifecycle
  delta's, with no precedence rule. Presentation now lives only on
  item.open/item.close; the delta schema requires it when the shape is
  `extension`, and the shape carries none.
- A persisted `{ asset: "./icons/x.svg" }` names a plugin file that is gone
  after uninstall and may change after upgrade, so it could not keep the
  renders-after-the-plugin-is-gone promise. Persisted icons are host glyphs
  only; a durable content-addressed asset form is WS3's (G10 records the
  doc's `asset` as that gap).

The G3 grammar snapshot is regenerated for the extension shape; snapshot
files are test-owned, so prettier now ignores them.

Co-Authored-By: Claude <noreply@anthropic.com>
The protocol accepted `presentation` and the assembler dropped it. It now
rides onto the canonical item: the open's value on item/started, and on
settlement the close's value wins while the open's survives when the close
carries none (the documented close-echo). Each settled shape on a
dual-settle keeps its own. Items that never carried one gain no key, so v2
streams are byte-identical.

Co-Authored-By: Claude <noreply@anthropic.com>
Only the bridge reported a grammar range. The runtime now states the range
its assembler speaks in the initialize params (`grammarVersions`, default
[2, 2] for runtimes that predate the field), `negotiateGrammarVersion`
picks the highest common version, and a bridge whose range shares nothing
with the assembler's fails startup with the same legible error a wrong
protocol version does. ASSEMBLER_GRAMMAR_VERSIONS stays [2, 2] until WS1a
implements the v3 shapes.

Co-Authored-By: Claude <noreply@anthropic.com>
The event and interaction wire widened (new item kinds, item/delegation/*
events, tool_use subject, optional presentation). Every addition is
tolerated by an older daemon, but the repository rule is to bump rather
than ship a widened wire on an untested compatibility assumption; the
mismatch moves enrolled machines onto a daemon whose runtime also
negotiates the delta grammar range.

Co-Authored-By: Claude <noreply@anthropic.com>
@SawyerHood
SawyerHood force-pushed the bb/provider-contract branch from 6ed9bf1 to 9828aac Compare August 21, 2026 06:23
SawyerHood added a commit that referenced this pull request Aug 21, 2026
… merge)

Duplicates PR #2153 (bb/provider-recordings, based on main). WS1b-codex needs
the recorded codex cells and the parity harness as its regression oracle, and
the stack base (WS1a #2136 on the contract #2124) does not contain them. Drop
this commit when the stack rebases onto main after #2153 lands.
SawyerHood added a commit that referenced this pull request Aug 21, 2026
… merge)

Duplicates PR #2153 (bb/provider-recordings, based on main). WS1b-codex needs
the recorded codex cells and the parity harness as its regression oracle, and
the stack base (WS1a #2136 on the contract #2124) does not contain them. Drop
this commit when the stack rebases onto main after #2153 lands.
SawyerHood added a commit that referenced this pull request Aug 21, 2026
… merge)

Duplicates PR #2153 (bb/provider-recordings, based on main). WS1b-codex needs
the recorded codex cells and the parity harness as its regression oracle, and
the stack base (WS1a #2136 on the contract #2124) does not contain them. Drop
this commit when the stack rebases onto main after #2153 lands.
…ic SDK surface

@get-bb/plugin-sdk@0.4.10 is published. This stack adds v3 bridge exports
to provider-bridge.ts and target-state declaration fields to
backend-contract.ts, so the packed SDK no longer matches the published
tarball and the npm version guard fails. Bumped with
scripts/bump-plugin-sdk.mjs --patch (both version files together); the
guard passes locally.

Co-Authored-By: Claude <noreply@anthropic.com>
@SawyerHood
SawyerHood merged commit b1bd629 into main Aug 21, 2026
13 checks passed
@SawyerHood
SawyerHood deleted the bb/provider-contract branch August 21, 2026 14:20
SawyerHood added a commit that referenced this pull request Aug 21, 2026
…2136)

Stacked on #2124 (`bb/provider-contract`). WS1a of the provider-plugin
migration: the generic assembler, one streaming + one usage dialect,
extension ingest validation, the published testing kit, the scripted
echo bridge as the harness default, and — last commit — the deletion of
the legacy `ProviderAdapter` path and the v2 delta dialects.

**Do not merge.** Coordinator reviews, Sawyer merges the stack.

## What was wrong

The contract PR landed the grammar v3 vocabulary but left the assembler
stubbed (`UnsupportedDeltaShapeError` in every v3 shape and in
`extension.state`), kept two streaming and two usage dialects, validated
no extension payload, published no testing kit, and the runtime still
carried a legacy `ProviderAdapter` interface whose only non-bridge
implementation was a 674-line fake adapter driven by a legacy-dialect
script (the integration harness's default provider).

## What changed (one commit per layer; the last deletes)

1. **Assembler builds the v3 core kinds** (`92ae9cd`) — `fileRead`,
`search`, `planSteps` open pending and settle from the terminal shape
like `command`; a foreground `delegation` settles through
`item/completed`, a `background: true` delegation is thread-attached
like a background task (`item/delegation/progress|completed`, no turn
needed, survives turn settlement and `session.ended`).
`ASSEMBLER_GRAMMAR_VERSIONS` → `[2, 3]`.
2. Presentation persistence shipped upstream in #2124 (`fc88906`);
nothing to do here.
3. **Extension kinds + ingest validation** (`f174c5d`) — `extension`
items and the new `thread/extensionState/updated` event assemble. The
server validates every extension payload against the owning plugin's
declared Standard Schema at ingest
(`apps/server/src/internal/extension-payloads.ts`; registrations carry
the validators, the registry resolves `"<pluginId>/<name>"` through the
plugin-id prefix; 64 KiB cap). An undeclared kind, a schema miss, a
validator error, or an oversized payload is persisted as
`provider/unhandled` in the same batch slot — G11-visible, never
dropped, never stored unvalidated. `extensionKindSchema` parses to the
`ExtensionKind` type.
4. **One streaming dialect, one usage dialect** (`cce9415`) — every text
stream is an item keyed like any other:
`item.textDelta`/`item.textClose`, anonymous streams keyed by
`key.channel` (+ `parentRef`). `usage { total, last, modelContextWindow
}` is forwarded verbatim; bridges that report per turn (claude, pi)
accumulate with the bridge kit's `addTokenUsage` and reset at
`session.reset`; codex sends `contextWindow` (now with `providerTurnId`)
beside it. All four bridges + the echo example migrated. **Calibration
goldens unchanged** for codex, claude, acp, pi.
5. **`provider/recovery`** (`e5f5a3b`) — decoded by the adapter,
forwarded to the runtime's new `onProviderRecovery` hook (the daemon
logs it; WS4 acts per kind). Grammar negotiation shipped upstream
(`0816b4c`).
6. **Published testing kit** (`03815e3`) —
`@get-bb/plugin-sdk/provider-bridge/testing`: conformance kit, the real
assembler, delta→event collector, JSON-RPC harness, calibration
normalizer. Framework-agnostic (`captureBridgeJsonRpcOutput` patches
`process.stdout.write`, no `vi`). `experimental_` value names +
`docs/api_to_audit.md` entry; G10 doc-sync test asserts the entry. The
assembler moved into `@bb/provider-bridge-protocol` (`assembler`
subpath) — the SDK cannot depend on the runtime (cycle). The echo
example and every first-party bridge suite import only
`@get-bb/plugin-sdk/provider-bridge` + the testing entry; the echo
example's `@bb/*` devDependencies are gone.
**Scripted echo bridge as the harness default** (`3d3cb9e`) —
`tests/scripted-echo-provider` (the echo bridge + scripted directives:
`delay:`, `approve:`, `ask_user`, `call_tool:`, `hold_turn`,
`fail_turn:`, …; session/process behaviour via
`providerOptions.scripted` / `SCRIPTED_ECHO_OPTIONS`;
`SCRIPTED_ECHO_RECORD_PATH` records every request,
`SCRIPTED_ECHO_PROCESS_LOG_PATH` every process step). Passes the
conformance suite. The integration harness has no `adapterFactory` seam:
the fake providers are declarations backed by the built scripted
artifact, run by the daemon through the real adapter.
7. **Deletion** (`a9a3950`) — `ProviderAdapter` → concrete
`BridgeProtocolAdapter`; `adapterFactory` /
`createAgentRuntimeWithAdapters`; the fake adapter + script;
`message.delta/close`, `usage.turn/exact` from schema + assembler;
assembler `[3, 3]` and every bridge reports it (a bridge that predates
`grammarVersions` reads as v2 and is refused at the handshake; the
conformance handshake scenario checks the same). Runtime unit suites +
the daemon's thread.stop race suite run the scripted echo bridge through
the real bootstrap + adapter + assembler; the process manager gains a
`createAdapter` seam for raw-script spawn/stderr/exit tests.
`HOST_DAEMON_PROTOCOL_VERSION` → 148.

### Tests deleted (subject no longer exists)
- command-contract: "rejects required adapter commands that return no-op
plans", "rejects no-op steer commands", the noop half of "rejects no-op
stop commands" (only the fake adapter's `buildCommandPlan` seam could
plan a noop; handshake gating is pinned in
`bridge-protocol-adapter.test.ts`).
- lifecycle: "passes Codex-shaped thread/start ids to accepted command
translation" (`translateAcceptedCommand` is a no-op for bridges),
"preserves merged shell env when reconfiguring a thread" (the
session-rebuild path is unreachable: bridges classify every settings
change as `live`; env is covered by the start/resume tests), "drops a
delta into an item nothing opened, with a visible warning" (the only
seam that could feed a malformed event was the `translateEvent`
override; the grammar gate is exercised by the new replayed-turn bridge
test).
- input-accepted: "suppresses provider-emitted user message echoes"
(bridges never emit `userMessage`).
- multi-thread: "maps thread/started before identity", "drops unscoped
provider events" (legacy `thread/event` dialect routing; every
`thread/delta` names its bb thread id).
- process-lifecycle: "continues startup when an optional post-initialize
read is unsupported" (the only post-initialize request is the handshake
itself).
Everything else is ported faithfully; literal-id assertions became
assertions on the assembler-minted ids (#1224), `AdapterCommand`
recordings became request-record assertions on the same wire facts.

## Regression oracle status

- **Parity replay (A2)**: `bb/provider-recordings` does not exist on
origin; not in the base. Coordinator requires it before merge.
- **Corpus row snapshots (A4, #2121)**: not in the base.
- **G1 ratchet (#2120)**: not in the base. This PR adds no provider-id
literal to core (the scripted bridge's codex-shaped archived error is
test-only).
- **Conformance kit**: green for echo, scripted echo, codex,
claude-code, acp, pi.
- **Calibration goldens**: byte-identical for codex, claude, acp, pi
through the dialect migration.

### Intended byte-level difference (allowlist)
- **WS1a #2136, layer 4**: a provider-named text item that streamed
before `session.ended` now settles with its accumulated text instead of
its opened (empty) shape. Reason: one streaming dialect means the
assembler owns the stream text for named items too; losing streamed text
on interrupt was the v2 behaviour, not a feature.

### Wire discipline
`HOST_DAEMON_PROTOCOL_VERSION` 147 → 148: the daemon emits a new event
type (`thread/extensionState/updated`) and its bridges speak grammar v3
only, which a 147 daemon would refuse at the handshake.
`PROVIDER_BRIDGE_PROTOCOL_VERSION` stays 2 (envelope and methods
unchanged; the grammar range gates).

## Gates (all `--concurrency 4`)

Typecheck — green, 27 tasks: `@bb/domain @bb/provider-bridge-protocol
@bb/agent-runtime @bb/server @bb/host-daemon @bb/host-daemon-contract
@get-bb/plugin-sdk @bb/thread-view @bb/db` and `provider-codex
provider-claude-code provider-acp provider-pi echo-provider
scripted-echo-provider @bb/integration-tests @bb/app @bb/mobile
@bb/cli`.

Tests:
| package | result |
|---|---|
| @bb/domain | 148/148 |
| @bb/provider-bridge-protocol (incl. the assembler suite) | 213/213 |
| @bb/host-daemon-contract | 52/52 |
| @get-bb/plugin-sdk | 127/127 |
| @bb/thread-view | 379/379 |
| @bb/db | 406/406 |
| @bb/agent-runtime (incl. pi conformance) | 336/336 |
| bb-plugin-provider-codex | 172/172 |
| bb-plugin-provider-claude-code | 263/263 |
| bb-plugin-provider-acp | 181/181 |
| bb-plugin-echo-provider / scripted-echo-provider | 2/2, 1/1 |
| @bb/host-daemon | 556/556 |
| @bb/integration-tests (fake stack on the scripted bridge) | 55/55 |
| @bb/server | 1821/1823 — two pre-existing, environmental locals:
`internal-skill-trees` (umask 0664 vs 0644, passes in CI) and
`plugin-update` "waits one full interval" (5 s timeout under load; test
and subject untouched by this PR) |

Perf (assembler micro-benchmark, 20 000 mixed turns = 1 340 000 deltas →
1 380 000 events, 3 runs each, same workload): contract head (v2
dialect) min 457 ms / 2.93 M deltas/s; this branch (v3 dialect) min 436
ms / 3.07 M deltas/s — ~4 % faster, heap delta no worse. Within the +10
% gate.

> 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
…rdings, and the parity harness (#2153)

Stacked on #2136 (WS1a,
`bb/ws1a-assembler-testing-kit-stack-on-2124-thr_jkbj56vr97`), itself
stacked on the contract #2124. Landing order: contract → WS1a → this →
WS2a → #2121 → codex → claude.

**Why stacked, not independent.** `@bb/provider-parity` and the three
`bridge.recorded-conformance.test.ts` files import
`createBridgeDeltaEventCollector`, which lives at
`@bb/agent-runtime/test/bridge-delta-assembly` on `main` and at
`@bb/provider-bridge-protocol/testing` on WS1a. No ordering of two
independent PRs yields a buildable `main`, so this PR follows the move
(and drops `provider-parity`'s `@bb/agent-runtime` dependency). The
recorded `bridge→runtime` lanes are grammar v2, which WS1a's v3-only
assembler refuses, so every replayable cell now also carries a
`bridge→runtime.current.ndjson` written by `pnpm --filter
@bb/provider-parity rerecord --plan-with <main checkout>` through this
branch's bridges; they assemble to the **unchanged** pins in
`row-counts.json`. The 10 pi cells (in-process SDK, not replayable) keep
their pins and are reported, not failed.

**Do not merge.** Coordinator reviews, Sawyer merges the stack.

## What was wrong

The provider-plugin migration will break byte-equivalence with today's
translators on purpose, which removes the regression oracle the goldens
provide. Calibration sessions are scripted fakes and the integration
suite runs a fake adapter, so nothing checks a bridge against what the
provider CLIs really emit across the behavior matrix. Design:
"Regression confidence" (A2, A3) and "Corpus" in the ideal-state
provider API spec. No bridge may migrate before this lands.

## What changed

**Bridge record mode** (`BB_PROVIDER_BRIDGE_RECORD_DIR`, off by default,
additive)
- `bridge-worker-entry.ts` tees both sides of the runtime wire for every
bridge, first- or third-party, before the bridge module loads.
- Bridges tee their provider child with
`experimental_recordProviderChildIo(child, { threadId })` (codex
`app-server-connection.ts`, acp `agent-connection.ts`). Claude's pipe
belongs to the Agent SDK, so `sdk-session.ts` takes the SDK's
`spawnClaudeCodeProcess` seam over when
`experimental_isProviderBridgeRecording()`. Pi runs in-process and
records its SDK event boundary (`AgentSessionEvent` in,
prompt/abort/compact out).
- Layout `<dir>/<providerId>/<threadId>/<direction>.ndjson` (`_process`
for lines that belong to no thread); one `{ts, run, seq, dir, line}` per
line, appended per line, never buffered. Responses land in the scope of
the request they answer. `run` identifies the bridge process so a thread
served by several processes merges back in order.
- The daemon forwards the variable from its own env (not the shell env,
which doubles as the agent's shell environment); the runtime appends the
provider id. `withoutBridgeRuntimeEnv` and the existing `BB_*` allowlist
both strip it from provider children.
- Docs: `docs/provider-bridge-protocol.md` ("Record mode"),
`docs/debugging-and-qa.md`, `docs/configuration.md`,
`docs/api_to_audit.md` (both new `experimental_` SDK members).

**Recordings** — 52 redacted cells under
`packages/provider-bridge-protocol/recordings/<provider>/<cell>/` with a
`manifest.json` each (provider, cell, CLI version, date, description,
lane line counts). `scripts/provider-recordings/redact.mjs` rewrites
paths under `$HOME`, emails, `ls -l` owner columns, token shapes
(`bbde_`, `ghp_`, `github_pat_`, `sk-`, `sk-ant-`, `xox?-`, `bbcm_`,
JWTs, bearer values, `Authorization` headers), secret-shaped env keys,
collapses Claude's `system/init` and `control_response` catalogs to
names, and trims strings over 2,000 chars to head/tail with a marker. It
is idempotent and exits 3 if any pattern survives. `package-cells.mjs`
cuts the raw per-thread recordings into cells. Raw recordings stay in
`~/.bb/provider-recordings/raw` (gitignored).

**Parity harness (A2)**
- `@bb/provider-bridge-protocol/testing/parity`: `replayRecording`
drives the recorded `runtime→bridge` lane into a bridge process and
`replay-provider-child.mjs` plays the provider lanes back as the
bridge's child — the recording *is* the script. The child gates each
recorded provider line on the live bridge's own writes (method/subtype
for requests, id for responses), maps bridge-minted ids to recorded
ones, cuts the lanes into one segment per spawned child, paces
spontaneous lines behind the harness's cursor so steers and interrupts
land between the same two provider lines they did live, and never hangs
a divergent bridge (stall → generic answer, logged). Serves JSON-RPC
(codex, ACP) and the Claude CLI control protocol. Each replay runs in
its own temp workspace and (for Claude) its own `CLAUDE_CONFIG_DIR`,
with a seeded source transcript per recorded `thread/fork`, so
recordings replay on any machine.
- `compareParity(old, new, allowlist)` diffs normalized events (the
resurrected `diffCalibrationStreams`, `#2140` had removed it),
normalized rows, and grammar drops. Allowlist entries are
`{provider|"*", cell|"*", layer, path (JSON pointer with `*`/`**`), pr,
reason}`; an entry that masks nothing is reported stale and fails.
- `@bb/provider-parity` wires the real delta assembler and
`@bb/thread-view` projection (mirroring `timeline.ts`), owns `pnpm
parity --old <checkout> --new . [--provider] [--cell] [--dump-dir]`, and
loads each leg's assembler and projector from that leg's own checkout
(its `@bb/provider-parity`, else the collector at its pre/post-WS1a
home), so a `main` `--old` leg assembles with `main`'s assembler.
- `parity.self.test.ts` (CI): every cell assembles to the counts pinned
in `recordings/row-counts.json` (events, rows; `provider/unhandled` and
grammar drops may only go down — G11), every allowlist entry names a PR,
reason, and pointer (staleness is judged by `pnpm parity --old <main>`,
which an old==new replay cannot), and every replayable cell replays
through the current bridge with zero event/row/grammar diffs and zero
stalls. `UPDATE_PARITY_ROW_COUNTS=1` rewrites the pins deliberately.

**Current bridge lanes (merged from #2177, reviewed as harness owner)**
— a recording is never rewritten. When a bridge change alters what the
bridge emits, `pnpm --filter @bb/provider-parity rerecord [--plan-with
<recording-time checkout>]` writes `bridge→runtime.current.ndjson`
beside the recorded lane; the self-suite and recorded conformance
pin/compare against it when present, and `pnpm parity` paces the old leg
from the recorded lane and the new leg from the current one. Also from
#2177: `pnpm parity --dump-dir`, a 300 ms drain before each replayed
runtime request and a 50 ms gap after a replayed response (the steer-ack
position race seen twice in CI). Two follow-ups on top: the harness's
own `initialize` response is kept out of re-recorded lanes, and
re-recorded lanes pass through `redact.mjs` before they are written (a
bridge error can quote the replay child's command line). 39 current
lanes are committed (every replayable cell) — see "Why stacked" above.

**Conformance** — `checkRecordedCellReplay` + `replayRecordedCells` add
the recorded-traffic scenario set (`recorded/<cell>/{replays,
events-schema-valid, grammar, turn-lifecycle, not-empty}`); each
first-party bridge gets `bridge.recorded-conformance.test.ts` over turn,
steer, stop, approval allow/deny, question, resume, fork.

No translation behavior change. No wire change;
`HOST_DAEMON_PROTOCOL_VERSION` is untouched.

## How you verified

On the stacked head `39066e19b` (WS1a base), one Turbo invocation at a
time at `--concurrency 4`: typecheck 12/12 tasks (protocol, parity,
agent-runtime, host-daemon, plugin-sdk, codex, claude-code, acp); tests
— protocol 218, codex 173, claude-code 262, acp 182 (each
`bridge.recorded-conformance.test.ts` green), parity self-suite 43/43
with `row-counts.json` untouched; `pnpm parity --old . --new .` 39
passed / 0 failed / 13 skipped; `pnpm rerecord --plan-with
/home/sawyer/projects/bb` 39 OK / 0 STALL; redaction sweep over
recordings + current lanes `0 survivors`, idempotent.

Earlier, on the `main`-based head `0923c1c59` (after merging #2177): CI
green (Checks, Package Smoke ×2, Tests app-1/2/3, integration, packages,
server); 21/21 Turbo tasks, 1,848 tests (protocol 101, parity 43,
agent-runtime 418, host-daemon 552, plugin-sdk 117, codex 173,
claude-code 262, acp 182); the steer/stop-interrupt cells stable across
3 repeated runs. New tests in this PR: recorder routing/tee/oversize,
worker-entry record tee, `withoutBridgeRuntimeEnv` strips the knob,
daemon forwards it to bridge processes but not the shell env, the
43-test parity self-suite, three recorded-conformance suites.

An earlier CI run on `main` failed in `packages` because ACP and Claude
replays depended on the recording machine (`cwd`, `~/.claude`
transcripts for `forkSession`, `PATH`); reproduced locally with the
directory moved away and a bare `HOME`, fixed as described above.

**Matrix** (`codex-cli 0.149.0`, `Claude Code 2.1.238` / Agent SDK
0.3.197, `cursor-agent 2026.08.11`, pi `@earendil-works/pi-coding-agent
0.84.0`, recorded 2026-08-21 through `scripts/bb-dev-app` + the `bb`
CLI):

| cell | codex | claude-code | acp-cursor | pi |
| --- | --- | --- | --- | --- |
| 1 turn (shell + edit + read) | ✅ | ✅ | ✅ | ✅ |
| 2 steer mid-turn | ✅ | ✅ | ✅ | ✅ |
| 3 stop mid-turn, new turn | ✅ | ✅ | ✅ | ✅ |
| 4 approval allow | ✅ | ✅ | ✅ | ⛔ pi has only `full` mode; nothing asks
|
| 4 approval deny | ✅ | ✅ | ✅ | ⛔ same |
| 5 user question | ✅ (bb tool) | ✅ (`AskUserQuestion`) | ✅ (bb tool) |
✅ (bb tool) |
| 6 subagent / delegation | ✅ (native `subAgentActivity`) | ✅ (`Agent`)
| ✅ (bb delegation) | ✅ (bb delegation) |
| 7 resume after `thread stop` | ✅ | ✅ | ✅ | ✅ |
| 8 fork | ✅ | ✅ | ✅ recorded as the agent's refusal: `cursor-agent`
advertises no `session/fork` (0 events, pinned) | ✅ |
| 9 plan mode | ✅ (`/plan` command mention) | ✅
(`claudeCodePermissionMode: plan`, plan approval interaction) | ⛔ no
plan command | ⛔ no plan command |
| 10 model list | ✅ (`_process` scope) | ✅ | ✅ | ✅ |
| 11 web search / fetch | ✅ (`webSearch` item) | ✅ (`WebSearch` +
`WebFetch`) | ✅ (Web Fetch tool) | ✅ (curl fallback; pi has no web tool)
|
| 12 compaction | ✅ | ✅ | ⛔ server 409: provider does not support manual
compaction | ✅ |
| 13 archived session resume | ✅ (archived natively via app-server
`thread/archive`; bridge unarchives and retries) | n/a | n/a | n/a |
| 13 empty rollout | ✅ (0-byte rollout → `failed to read session
metadata`) + bonus `missing-rollout` | n/a | n/a | n/a |
| 13 auth failure | ✅ real 401 ×11 via empty `CODEX_HOME` | ✅ "Not
logged in" via empty `CLAUDE_CONFIG_DIR` | ⛔ not attempted: no
config-dir knob to point at an empty store without touching the real
login | ⛔ not attempted: pi's keys live in its own config; no safe
override |
| 13 429 | none occurred naturally (not forced) | — | — | — |

**Redaction sweep** (`node scripts/provider-recordings/redact.mjs
packages/provider-bridge-protocol/recordings /tmp/redact-stack`):
```
redacted 241 recording files into /tmp/redact-stack (home=/home/sawyer → /home/user); 0 survivors
```
`diff -rq` between the committed fixtures (recordings + current lanes)
and that output is empty (idempotent). The only remaining occurrence of
the username is the public skill name `bb-global-skills:sawyer-voice` in
Claude's advertised skill catalog.

**Parity self-run** (`pnpm parity --old . --new .`): `39 passed, 0
failed, 13 skipped (52 cells)` — the 13 skips are the 3 process-scoped
`model-list` cells (no thread events) and the 10 pi cells (in-process
SDK, no child to replay; still pinned and assembled by the self-suite).
Every PASS line reports identical old/new counts, e.g. `codex/steer: old
87 events/3 rows, new 87 events/3 rows, unhandled 0→0, grammar drops
0→0`. The cross-checkout path was exercised too: `--old
/home/sawyer/projects/bb` (on `main`, no `provider-parity` package)
loaded that checkout's `@bb/agent-runtime` collector and bridge and
matched on all 16 codex thread cells.

**Sizes**: recordings 6.9 MB total including the 39 current lanes (6.2
MB without) — codex 1.6 MB, claude-code 1.2 MB, acp-cursor 932 KB, pi
2.5 MB recorded; largest cell `pi/turn-tools` 828 KB.

Fixes none (step-0 PR of the provider-plugin migration).

> 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
…le ProviderInfo (#2148)

> **Stacked on #2153 (recordings + parity harness) ← #2136 (WS1a) ←
#2124 (contract)** — re-stacked linearly at the coordinator's request so
the full stack merges conflict-free. Base: `bb/provider-recordings`. DO
NOT MERGE — coordinator review.

## What was wrong

Provider facts lived in core tables keyed by provider id, and the shared
server↔daemon execution contract carried Claude/Codex-named knobs:

- `PRODUCT_PROVIDER_ORDER`, `RESERVED_PROVIDER_ID_OWNERS`,
`PRODUCT_DEFAULT_PROVIDER_ID` in the server registry.
- Five `codex*`/`claudeCode*` keys on the shared `AppSettings`, resolved
in `thread-commands.ts` by `providerId === …`.
- `claudeCodePermissionMode`, `workflowsEnabled`, `memoryEnabled`,
`providerSubagentsEnabled` on `RuntimeThreadExecutionOptions` (the G2
allowlist entry), plus a `providerId !== "claude-code"` guard in the
runtime.
- Three copies of the Claude model catalog (plugin, server, app) and a
vendored `PLACEHOLDER_PROVIDER_INFOS` roster in the app; sign-in hints,
install URLs, brand prefixes and plan-mode copy in per-provider tables
on web and mobile.
- The contract PR added the additive declaration / `ProviderInfo`
fields; nothing populated or consumed them (G10 gaps: `family`,
`models`, `env`, `deriveProviderOptions`, `promptMode`).

## What changed

Spec: `WS2a-registry.md` layers 1–7 (docs/provider-plugin-api.md §1,
§6).

**L1 — `bb.providers.register` + single `ProviderInfo`.** New
`bb.providers` namespace on `BbPluginApi`;
`bb.agents.experimental_registerProvider` stays as an alias sharing the
same registration table (additive-then-delete: both work; the four
first-party `server.ts` files moved to the new call).
`buildPluginProviderRegistration` now projects `strings`,
`reasoningLevels` (declared options, or the coarse ladder labelled),
`serviceTiers` (declared, or `default`/`fast` when
`supportsServiceTier`), `extensionKinds` namespaced by the **owning
plugin id**, and `family`. New declaration fields, validated in the
shared host policy: `experimental_family`,
`experimental_models.fallback`, `experimental_env.passthrough`,
`experimental_deriveProviderOptions`.

**L2 — options hook + `promptMode`.** The server runs the owning
plugin's `experimental_deriveProviderOptions(ctx)` on every session/turn
command (`ctx = { threadId, projectId, model, permissionMode,
promptMode?, settings }`, `settings` = the plugin's own non-secret
`bb.settings` values, read synchronously) and sends the bounded-JSON
result as a REQUIRED `options.providerOptions`. `promptMode: "plan"` is
set only when the prompt carries the provider's *declared* `plan`
composer command (Pi never sees it). `claudeCodePermissionMode` + the
three booleans are deleted from `runtimeThreadExecutionOptionsSchema`,
`ProviderExecutionContext`, and the runtime's literal-id guard; the
bridge adapter merges the bag over the static bridge options instead of
rest-sweeping "provider-flavored" fields. `bridgeExecutionOptionsSchema`
gains `promptMode`. The Claude plugin maps `promptMode` to its native
plan flag inside its own bag; the Claude/Codex bridges already read
their knobs from the bag, so no bridge code changed.

**L3 — provider-scoped settings.** The five keys are gone from
`appSettingsSchema`; `provider-codex` defines
`memoryEnabled`/`subagentsDisabled`, `provider-claude-code` adds
`workflowsDisabled`, both derived into `providerOptions` by their hooks.
Drizzle data migration `0105_provider_settings_to_plugins` copies stored
values into `plugin_settings` (existing plugin rows win) and retires the
shared rows. `supportsWorkflows` is deleted from the declaration and
`ProviderServerCapabilities` (it only ever fed `workflowsEnabled`). Web:
the hardcoded Codex/Claude settings pages, `SETTINGS_PROVIDER_ENTRIES`,
`/settings/providers/:providerId` are gone; a generic **Settings →
Providers** bucket lists every registered provider with order + default
controls, and per-provider knobs live on the plugin's settings page.
Mobile: `ProviderSettingsScreen` and its route are gone (plugin detail
already renders `bb.settings` forms).

**L4 — flat ids + user order.** `RESERVED_PROVIDER_ID_OWNERS`,
`reservedProviderIdProblem`, `PRODUCT_PROVIDER_ORDER`,
`PRODUCT_DEFAULT_PROVIDER_ID` deleted. Collision = first live
registration wins (`assertProviderRegistrable` keeps only the "has a
bridge" check). `registry.list()` orders by an install rank the plugin
runtime supplies (bundled plugins by their `BUNDLED_PLUGINS` index —
reordered to codex, claude-code, pi, acp so today's picker order is
preserved — then others by `installedAt`, then registration sequence),
under two new app settings `providerOrder: string[]` and
`defaultProviderId: string | null` (`bb settings general …` works
unchanged, docs/configuration.md updated). `thread-default-policy`
resolves the default as the user's choice when registered+available,
else the first available listed provider.

**L5 — duplicate catalogs.** Server `claude-code-fallback-models.ts`
deleted; the model-list route serves `registration.fallbackModels` (from
the declaration) on a transient probe failure. App
`PLACEHOLDER_PROVIDER_INFOS` and `CLAUDE_CODE_PLACEHOLDER_MODELS`
deleted (a cold cache waits for the first probe; a remembered
per-routing roster still replays). The Claude catalog data moved to an
import-free `src/model-catalog-data.ts` (the server loads `server.ts`
with only the SDK root specifier resolvable, so it must not pull in
`@get-bb/plugin-sdk/provider-bridge`). Web + mobile usage banners, the
picker's brand strip, the missing-CLI install link, and the plan-mode
permission display now read `ProviderInfo.strings` — the mobile usage
table's keys (`claudeCode`, `cursor`) never matched the server's
provider-id keys, which this fixes as a side effect.
`effective-prompt-mode` keys on the presence of `strings.planModeCopy`,
not `providerId === "claude-code"`.

**L6 — directory.** `bb.sdk.providers.list()` already existed
(verified). New `app.experimental_useProviders()` (`{ status, providers:
ProviderInfo[] }`) in the SDK, app implementation, and test harness
(`providers` option); `provider-retry` and `automations` read provider
names from it instead of local tables. Icons remain vendored — see the
api_to_audit entry for why.

**`env.passthrough`** replaces the daemon's hardcoded
`BB_CLAUDE_CODE_EXECUTABLE` forwarding: `bridgeLaunch.envPassthrough`
(REQUIRED on the wire) names the variables; the runtime picks exactly
those from the daemon env into the bridge process env. #2153's
record-mode forward (`BB_PROVIDER_BRIDGE_RECORD_DIR`, read from the
daemon's own env) stays beside it in `providerProcessEnvFromShellEnv` —
it is a daemon concern, not a provider declaration.

**Wire:** `HOST_DAEMON_PROTOCOL_VERSION` 148 (WS1a) → **149**;
protocol.ts keeps all three log entries (147 contract, 148 WS1a, 149
this PR). Changes: `options` shape (fields removed, `providerOptions`
required, `promptMode` optional) and `bridgeLaunch.envPassthrough`
required.

**Docs / surfaces:** docs/api_to_audit.md (registration entry rewritten
for flat ids + install order; target-state-fields entry expanded with
the hook/models/env/family and migration; new
`experimental_useProviders` entry), bb-plugin-authoring SKILL.md
(`bb.providers.register` with every new field), docs/configuration.md
(Providers bucket, `providerOrder`/`defaultProviderId`, plugin-scoped
knobs), docs/provider-bridge-protocol.md alias rename, echo-provider
example.

### G10 doc-sync gaps (`provider-plugin-doc.test.ts`)

`family`, `models`, `env`, `deriveProviderOptions`, `promptMode` →
mapped to real fields. `maintenance` stays a gap, retargeted to the
stabilization audit: it is a pure fold of the three
`experimental_provider{Health,Usage,Installation}` booleans, and
declaring the same three facts twice during the transition would break
one-fact-one-place.

### Deliberately NOT done here (noted for WS4)

- `AgentRuntimeSkillRoot` is still a closed union keyed by first-party
id (WS1a's flag). Dissolving it needs the `skills/configure` root to
carry both the skills dir and the plugin root (Claude consumes the
latter), i.e. a bridge-protocol + Claude bridge change — WS4's lane.
- `provider-registry.ts` keeps 9 `isAcpProviderId` hits for the dynamic
ACP tier (custom agents resolved from config) — WS2b deletes the tier.

### Relation to WS1a

Now stacked on top of WS1a (#2136), so there is no sibling overlap to
reconcile: the restack commit ports the scripted-echo launch helper and
WS1a's new dispatch / extension-payload tests to this PR's shapes
(`envPassthrough`, `readSettings`, no `supportsWorkflows`) and keeps
`HOST_DAEMON_PROTOCOL_VERSION = 149` with all three log entries.

## Guardrails

**G1 (provider-literal ratchet,
`scripts/check-provider-literal-ratchet.mjs` from #2120 — not in this
base, so no baseline file is committed here; measured with the script
checked out from that branch):**

`214 → 149` references, `57 → 41` core files.

| file | before | after |
|---|---|---|
| `apps/app/src/components/pickers/model-brand-prefix.ts` | 2 | 0 |
| `apps/app/src/components/pickers/model-load-error-message.tsx` | 1 | 0
|
| `apps/app/src/components/settings/UsageLimitsSettingsSection.tsx` | 2
| 0 |
| `apps/app/src/components/settings/settings-nav.tsx` | 2 | 0 |
| `apps/app/src/hooks/queries/system-queries.ts` | 7 | 0 |
| `apps/app/src/views/SettingsView.tsx` | 4 | 0 |
| `apps/mobile/src/data/settings/usage-limits-model.ts` | 8 | 0 |
| `apps/mobile/src/screens/settings/ProviderSettingsScreen.tsx` | 4 | 0
(deleted) |
| `apps/mobile/src/screens/settings/SettingsScreen.tsx` | 2 | 0 |
| `apps/mobile/src/screens/shell/hrefs.ts` | 2 | 0 |
| `apps/server/src/services/providers/provider-registry.ts` | 24 | 9 |
| `apps/server/src/services/system/execution-options.ts` | 1 | 0 |
| `apps/server/src/services/threads/thread-commands.ts` | 6 | 0 |
| `apps/server/src/services/threads/thread-default-policy.ts` | 4 | 0 |
| `packages/agent-runtime/src/execution-options.ts` | 1 | 0 |
| `packages/client-core/src/prompt/effective-prompt-mode.ts` | 2 | 0 |
| `packages/plugin-sdk/src/app-contract.ts` | 2 | 0 |

**G2 (`provider-contract-purity`):** allowlist now **empty** —
`claudeCodePermissionMode` removed with the field.

**Registry equality:** `first-party-provider-plugins.test.ts` pins the
client-read `ProviderInfo` fields (id, displayName, logoUrl, available,
the three maintenance booleans, capabilities, composerActions) for codex
/ claude-code / pi / acp-cursor to their pre-PR values, plus the picker
order, and asserts the new projections are filled.

## How I verified

All with `--concurrency 4`:

- `turbo run typecheck` — **74/74 tasks green** (whole repo).
- `turbo run build` — 18/18 green. `turbo run lint` — green
(pre-existing app warnings only).
- `turbo run test` for `@bb/server` (193 files / 1817 tests; the only
failure is the known local-umask `internal-skill-trees` case, passes in
CI), `@bb/app` (414 files), `@bb/mobile` (124), `@bb/agent-runtime`
(31), `@bb/host-daemon` (47), `@bb/domain`, `@get-bb/plugin-sdk`,
`@bb/provider-bridge-protocol`, `@bb/host-daemon-contract`, `@bb/db`,
`@bb/client-core`, `@bb/cli`, `@bb/server-contract`, `@bb/thread-view`,
`@bb/scripts`, `@bb/plugin-build`, `@bb/demo-server`, and the plugins
`provider-claude-code`, `provider-codex`, `provider-acp`,
`provider-retry`, `automations`, `tasks` — all green.
- Tests that fail before / pass after:
`plugin-provider-registration.test.ts` (projection of
strings/tiers/levels/kinds/family/models/env + the hook bound to
settings + non-JSON rejection), `provider-registry.test.ts`
(install-rank order, user order overlay, unknown default → null, freed
ids), `thread-default-policy.test.ts` (user default + order),
`thread-runtime-config.test.ts` (providerOptions per provider, hook
context, promptMode only for plan-declaring providers),
`first-party-provider-plugins.test.ts` (client-field pin),
`migrate.test.ts` (0105 carries values, plugin rows win, shared rows
retired), `ProvidersSettingsSection.test.tsx`,
`execution-options.test.ts` (structural bag equality), mobile
`settings-models.test.ts` (usage rows keyed by provider id).

Not run: the live-CLI integration suite
(`agent-runtime#test:integration`) and the corpus gate (not in this
base).

> 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
…rpus row snapshots, timeline perf, permission matrix (#2121)

> **Stacked in the chain**: contract #2124 → WS1a #2136 → recordings
#2153 → WS2a #2148 → **this PR** → codex #2164 → claude #2178 → acp.
Base `bb/ws2a-registry-providerinfo-stack-on-2124-thr_hbi4kggyzb`. WS1a
deleted the `ProviderAdapter` interface and the fake adapter, so the
runtime permission matrix drives `handleRuntimeProviderRequest` through
the real bridge-protocol adapter built for the scripted echo launch
(`createProviderForId` + `createScriptedEchoLaunch`), with the
initialize handshake setting `approvalEnforcedBy` and a canonical
`interaction/request` on the wire. The 80 outcomes pinned on `main` are
unchanged; the `tool_use` subject the v3 contract added gets 20 measured
cells of its own (the type-level union guard demanded them).
`resolvePermissionEscalation` now takes only the initiator, so the
server table is 3 initiator cells + the 54 policy-shape cells. The row
snapshots minted on `main` compare byte-identical (307/307, 0 diffs)
under WS1a, #2153, and WS2a. Pre-stack history: `4af0367`.

## What was wrong

The provider-plugin migration abandons byte-equivalence with the old
goldens on purpose, which removes the regression oracle. Before any
provider code moves, `main` needs machine checks on real data that every
later layer can run: projected rows for the 307 production threads in
the private corpus (A4), a timeline-build and event-size baseline, and
the permission-decision matrix (A5/G12) pinned as a literal table before
WS5 changes the unions. None of these existed.

## What changed

No production code changes. Tests, test helpers, one script, one turbo
task, docs.

- **Corpus reader** — `packages/test-helpers/src/provider-corpus.ts`:
`corpusAvailable()`, `listCorpusThreads({ provider?, reasons? })`,
`loadCorpusThread(id)`. Reads `BB_PROVIDER_CORPUS_DIR` (`manifest.json`,
`threads/<provider>/<id>/{meta.json,events.ndjson}`), validates rows at
the boundary with zod (ids must be one safe path segment), resolves each
thread through its manifest entry and fails unless `meta.json` and every
event row agree on id, provider, reasons, and row count, and decodes
event payloads with `@bb/domain` `parseStoredThreadEvent` +
`buildThreadEventRow` — the same path as the server's
`parseStoredEventRow`. `.gitignore` gets `**/provider-corpus/**` with
re-includes for the two in-repo directories of that name.
- **Row snapshots** —
`apps/server/test/provider-corpus/row-snapshots.test.ts` +
`corpus-harness.ts`. Each thread is inserted into in-memory SQLite with
its original ids, sequences, and timestamps (raw `INSERT` so nothing is
minted), then every timeline page is built via
`buildThreadTimelineWithProfile` with the options the route uses — event
budget from `defaultFeatureFlags`, inline-output limit from
`DEFAULT_MAX_INLINE_OUTPUT_CHARS`, display name and plan command from
the real provider registry (`createTestProviderRegistry()` loads the
first-party plugin declarations) through `resolveProviderPlanCommand`,
unhandled ops included, output truncation + preview — following the
route's own `olderCursor`. Two variants per thread: `default` (turn rows
summarized) and `nested` (`includeNestedRows`, children materialized).
Snapshots go to
`$BB_PROVIDER_CORPUS_DIR/snapshots/rows/<provider>/<threadId>.json`,
keys sorted. Nothing is blanked: no wall-clock value reaches the rows,
and write mode proves it by projecting every thread twice and requiring
byte equality. Compare mode fails on any diff not covered by
`snapshots/allowlist.json` (`threadId` | `provider` | `"*"` scope,
JSON-pointer or `*`/`**` glob path, `pr`, `reason`), prints a unified
diff for the first 3 differing threads plus a count, lists the entries
it used, and fails on entries that cover nothing.
- **Perf baselines** — `timeline-perf.test.ts`: 10 largest threads per
provider, latest page and full page walk, 5 profiled builds each after a
warm-up, stage p50s from `ThreadTimelineBuildProfile`, persisted `data`
bytes median/p95/total, rows produced. Written to
`snapshots/perf-baseline.json`; compare fails at baseline × 1.10 for
build cost and × 1.15 for median event size. **Deviation from the brief,
with data:** the gate uses a normalized cost — min build time ÷ min time
of a fixed CPU workload that shares no code with the timeline (JSON
codec + sort over a deterministic document, run once per sample right
before the builds) — not raw p50/p95. Raw p50 of the same commit swung
up to 30% between two back-to-back runs on this 16-core box at load ~6
(14 of 20 threads tripped a literal 1.10 gate on the very next run);
each side's minimum discards its own contended samples, interleaving
keeps both minima in one short window, and a workload outside the
timeline path means a uniform regression still moves the ratio. Raw
p50/p95 are still recorded and printed. Up to 3 attempts per thread
(write mode keeps the median attempt; compare stops at the first pass),
a 5 ms floor for tiny latest-page builds, and compare mode refuses a
baseline written with different gate settings. The table header reports
the load average and flags an oversubscribed machine.
- **CI micro-benchmark** — same file, no corpus: `synthetic-thread.ts`
builds a 10,019-event thread (every item kind, deltas, background tasks,
usage events) and walks all 12 pages. Gate: minimum of 5 walks under
1,500 ms (local minimum 150–170 ms; the ceiling is ~10× so a slow runner
passes while a quadratic regression still fails).
- **Permission matrix** —
`packages/agent-runtime/src/permission-matrix.test.ts`: the runtime
chokepoint `handleRuntimeProviderRequest` over permission policy (5
members of the discriminated union) × approval subject (5: command,
file_change, permission_grant, plan, tool_use) × `approvalEnforcedBy`
(2) × deny availability (2) = 100 cells, each a literal; `satisfies
Record<CellKey, Outcome>` plus `SameUnion` type guards against the
domain unions (dropping a row or a union member fails `tsc`, verified on
the stack), plus runtime assertions that every local subject kind parses
as a payload. The request goes through WS1a's real bridge-protocol
adapter. `apps/server/test/permissions/permission-matrix.test.ts`:
`resolvePermissionEscalation` over the 3 initiators and the 54-cell
runtime-permission-policy shape cross product (5 accepted), with the
reviewer vocabulary pinned to the policy union at the type level.
- **Seeds** — `scripts/provider-corpus/snapshot-rows.sh
[write|compare]`, `@bb/server#test:provider-corpus` (uncacheable,
`passThroughEnv` for the two variables — strict turbo env mode strips
them from the plain `test` task), and a "Provider corpus" section in
`docs/debugging-and-qa.md`.

### Permission matrix (runtime)

Outcome: `forward` = reaches `onInteractiveRequest` (user decides);
`auto-deny` = runtime answers deny; `encode-error` = runtime wants to
auto-deny but `deny` is not in `availableDecisions`, so the provider
gets a JSON-RPC error. Subject kind (command, file_change,
permission_grant, plan, tool_use) never changes the outcome, so the
table is collapsed over it (each row below is 5 cells).

| policy (mode/escalation) | approvalEnforcedBy | deny available |
outcome |
| --- | --- | --- | --- |
| accept-edits/ask | runtime | yes / no | forward |
| accept-edits/ask | provider | yes / no | forward |
| accept-edits/deny | runtime | yes | **auto-deny** |
| accept-edits/deny | runtime | no | **encode-error** |
| accept-edits/deny | provider | yes / no | forward |
| auto/ask | runtime | yes / no | forward |
| auto/ask | provider | yes / no | forward |
| auto/deny | runtime | yes | **auto-deny** |
| auto/deny | runtime | no | **encode-error** |
| auto/deny | provider | yes / no | forward |
| full/– | runtime | yes / no | forward |
| full/– | provider | yes / no | forward |

Bridge-kit predicate: `shouldAutoDenyInteractiveRequest` → ask: false,
deny: true, null: false.

Server escalation by initiator: user → ask; agent → deny; system → deny.
(On `main` the function also took the thread and was measured
identically for root, delegated-child, and fork threads; WS1a removed
the unused argument.)

Accepted runtime policy shapes (5 of 54):
accept-edits/workspace/user/{ask,deny},
auto/workspace/automatic/{ask,deny}, full/full/–/–.

Observations (pinned, not fixed):

1. Permission **mode** never auto-decides anything in the runtime or the
server. Only escalation does, and escalation is purely a function of the
turn initiator. Mode is translated into provider-native settings (codex
`approvalPolicy`/sandbox, Claude SDK `permissionMode`) and enforced by
the provider. So a runtime-enforced provider in `full` mode that did
send an approval would prompt the user.
2. The `encode-error` cells: on an agent- or system-initiated turn, a
runtime-enforced provider whose approval omits `deny` receives a
JSON-RPC error instead of a decision. Codex forwards the provider's own
decision list, so this is reachable in principle; no first-party bridge
omits deny today.
3. `plan` approvals are treated like any other subject by the runtime:
on a system-initiated turn with a runtime-enforced provider they are
auto-denied. Claude is provider-enforced and always forwards
`ExitPlanMode`, so only codex plan approvals can hit this.

## How you verified

- `pnpm exec turbo run typecheck --filter=@bb/server
--filter=@bb/test-helpers --filter=@bb/agent-runtime` — 6 tasks
successful.
- On the WS1a stack (`--concurrency 4`): `pnpm exec turbo run typecheck
--filter=@bb/agent-runtime --filter=@bb/server
--filter=@bb/test-helpers` green; `pnpm exec turbo run test
--filter=@bb/agent-runtime --filter=@bb/test-helpers` — 31 files, 434
tests passed (102 of them the matrix); `pnpm exec turbo run test
--filter=@bb/server` — 197 files, 1,886 tests passed, the one failure
again the local umask assertion. With the corpus set, the row snapshots
minted on `main` compare byte-identical under WS1a's v3 assembler:
307/307, 0 diffs.
- On `main` before the stack, corpus absent: `pnpm exec turbo run test
--filter=@bb/agent-runtime --filter=@bb/test-helpers` — 32 files, 504
tests passed (82 of them the matrix). `pnpm exec turbo run test
--filter=@bb/server` — 196 files passed, 1 skipped (the row-snapshot
suite), 1,884 tests passed; the single failure is the pre-existing
`internal-skill-trees` file-mode assertion (this checkout's umask 0002
yields 0664 where the test expects 0644; it fails on clean `main` here
and passes in CI). The two corpus suites report as skipped; the
synthetic benchmark runs (`Synthetic 10019-event thread: 12 pages, 1165
rows projected, full walk p50 174 ms`).
- Corpus present: `scripts/provider-corpus/snapshot-rows.sh compare` → 2
files, 328 tests passed (307 row snapshots + 20 perf threads + 1
synthetic).
- Row snapshot write mode: **307 threads, 93,262 rows (top-level +
nested children), 270.5 MB (283,656,293 bytes), 96.9 s wall**; compare
mode on the same commit: 73.1 s, 0 diffs. After switching the harness
from copied provider literals to the real registry, compare was still
307/307 with 0 diffs — the snapshots are byte-identical.
- Perf gate on the final estimator: baseline written at load 9.2,
compare on the same commit at load 5.7 passed 20/20 threads on the first
attempt, ratios 0.80–1.09× of baseline. Every one of the 330,626 corpus
events decodes with today's `parseStoredThreadEvent` (426 MB of `data`).
- Allowlist machinery exercised by mutating one snapshot: compare fails
with the unified diff; an entry covering
`/variants/*/pages/*/rows/*/text` for that thread passes; a stale entry
fails with "every snapshots/allowlist.json entry must cover at least one
diff".
- Exhaustiveness guards exercised: deleting one matrix row → `TS2741
Property '"full/-|plan|provider|deny-unavailable"' is missing`; dropping
`"provider"` from the enforcer list → `SameUnion` becomes `false` and
the `satisfies` rejects the extra keys.

### Perf baseline (write mode, this commit, load 9.2/16 cores; ms; norm
= min build ÷ min `json-sort-v1` calibration)

| thread | provider | events | data bytes p50/p95 | latest rows | latest
p50/p95 ms | latest norm | pages | walk rows | walk p50/p95 ms | walk
norm |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| thr_62bp724ett | claude-code | 10207 | 570/6375 | 18 | 23.3/27 | 0.103
| 8 | 241 | 922.3/1228 | 4.855 |
| thr_zqfgksiw39 | claude-code | 9012 | 365/3587 | 50 | 90.5/119 | 0.388
| 7 | 279 | 532/564.4 | 2.466 |
| thr_zc9yxt8mwk | codex | 8795 | 316/3025 | 10 | 24.9/58.3 | 0.102 | 5
| 45 | 323.1/395.5 | 1.510 |
| thr_j4hfucc5bb | claude-code | 7679 | 331/1285 | 52 | 29.9/35.5 |
0.132 | 8 | 335 | 404.6/472.4 | 1.591 |
| thr_f9jfidwbx8 | claude-code | 6163 | 454/4912 | 26 | 19.5/23.7 |
0.156 | 6 | 132 | 198.7/225.5 | 1.720 |
| thr_w6i678ha85 | claude-code | 5826 | 371/2820 | 19 | 25.7/40 | 0.204
| 3 | 37 | 149.6/172.4 | 1.163 |
| thr_qvau3b2d5b | claude-code | 5685 | 355/3613 | 111 | 36.9/50.4 |
0.298 | 5 | 168 | 154.3/173.4 | 1.311 |
| thr_2pkc3q5imw | codex | 5487 | 316/3642 | 3 | 32.9/36.6 | 0.307 | 4 |
15 | 87/89.5 | 0.808 |
| thr_7wvd28jzpj | claude-code | 5015 | 345/3497 | 36 | 25.8/31.6 |
0.219 | 3 | 94 | 130.7/137.4 | 1.102 |
| thr_nbghhtnurj | codex | 4341 | 316/8824 | 11 | 20.8/27.8 | 0.180 | 4
| 35 | 90.4/94.6 | 0.849 |
| thr_wb9c2q8gm4 | claude-code | 4228 | 378/2469 | 65 | 31.3/36.4 |
0.268 | 3 | 150 | 86.3/89 | 0.801 |
| thr_ksr44swb3q | codex | 4063 | 316/4513 | 10 | 18.7/20.2 | 0.178 | 3
| 23 | 64/67.4 | 0.589 |
| thr_tsfz8rtt72 | codex | 4055 | 316/4571 | 18 | 20.6/22 | 0.185 | 2 |
29 | 71/74.3 | 0.633 |
| thr_qe8y73b26t | claude-code | 3880 | 245/991 | 2 | 2.8/2.9 | 0.026 |
2 | 9 | 54.3/55.5 | 0.520 |
| thr_n3xqyz69nk | claude-code | 3454 | 341/4341 | 42 | 31.6/35.4 |
0.307 | 4 | 59 | 84.8/87.8 | 0.824 |
| thr_nm27yahx34 | codex | 3314 | 174/5030 | 17 | 14.3/17.5 | 0.135 | 3
| 61 | 54.6/56.3 | 0.519 |
| thr_ssix3mhxk5 | codex | 3088 | 316/3703 | 44 | 12.8/15 | 0.122 | 3 |
73 | 50/52.5 | 0.478 |
| thr_72dhrw3s8a | codex | 3067 | 174/1659 | 5 | 35/37.3 | 0.305 | 2 |
14 | 45.6/63.9 | 0.430 |
| thr_u8agadfekn | codex | 2892 | 334/2361 | 5 | 51.2/52.5 | 0.453 | 1 |
5 | 49/49.5 | 0.433 |
| thr_me47xr5c3y | codex | 2466 | 316/4318 | 55 | 21.2/22.1 | 0.202 | 2
| 99 | 35.1/36.5 | 0.347 |

Part of the provider-plugin migration: the Step 0 baselines PR from the
design spec's "Regression confidence" section. No tracking issue.

> AGENT GENERATED: by Claude Code (claude-marshmallow-ht-eap)

---------

Co-authored-by: Claude <noreply@anthropic.com>
SawyerHood added a commit that referenced this pull request Aug 21, 2026
…bridge to grammar v3 with presentation (#2164)

Stacked on #2121 (corpus harness + permission matrix,
`bb/provider-baselines`) ← #2148 (WS2a) ← #2153 (recordings) ← #2136
(WS1a) ← #2124 (contract). WS1b-codex of the provider-plugin migration:
the codex bridge speaks grammar v3 with a presentation on every item,
its natives map to the core kinds, bb-injected tools carry their
presentation, goals and the macOS permission profile are codex extension
kinds, and the codex v2 path is deleted.

**Do not merge.** Coordinator reviews, Sawyer merges the stack.

## Stack

contract #2124 → WS1a #2136 → recordings #2153 (incl. #2177 and the
`PARITY_INITIALIZE_ID` follow-up) → WS2a #2148 → corpus harness #2121 →
**this PR** → WS1b-claude #2178. Six commits: the five codex layers
below plus one `parity:` commit carrying this PR's allowlist entries and
the codex `bridge→runtime.current.ndjson` lanes re-recorded against this
bridge with #2153's `pnpm rerecord --provider codex --plan-with <main
checkout>`. No vendored harness copies remain (they were needed only
while #2153 was outside the stack). The A4 corpus check was run from a
throwaway worktree with #2121's harness cherry-picked on top, since
#2121 is not in the stack.

## What was wrong

The codex bridge emitted v2-shaped items: no presentation (core
thread-view kept codex's tool-name tables), native sub-agents as `tool`
items named `spawnAgent`, `update_plan` as a turn-level event the UI
discards, goals as core `thread/goal/*` events, bb-injected tools
without `server`, and a macOS permission profile on a command approval
failed the whole approval. Open work rode an out-of-band
`thread/openWork` notification.

## What changed (one commit per layer; the last deletes)

1. **Presentation on every item.open/close** —
`plugins/provider-codex/src/presentation.ts` is the one place codex
tool-name knowledge lives: shell commands (wrapper stripped from the
headline), file edits, the bundled `node_repl` server ("Ran JavaScript"
with the call's title), other MCP servers by tool name, dynamic tools,
collab verbs, web search/fetch, image views, reasoning, messages, plans,
compactions, the synthesized sub-agent spawn. An invariant suite drives
one item per codex native through the real translator and asserts every
lifecycle delta carries one.
2. **Delegation + planSteps** — the synthesized native sub-agent is a
foreground `delegation` (`childRef` = agent thread id, `label` =
agentPath); a follow-up to a settled agent re-opens the same item and
the row closes only when the agent owes nothing more (the exact
open-work predicate); a dead app-server child settles its open
delegations as failed on the wire. A collab call that names its receiver
is a delegation to it; a bare `wait` stays a tool item with its collab
presentation. `turn/plan/updated` becomes a settled `planSteps` snapshot
per update. `RuntimeBackgroundWorkState` counts a pending delegation as
open work. **Thread-view projects a `delegation` item to the existing
delegation row with the child content nested** — without this the row
and every child message under it vanish before the projection workstream
lands (the projection suppresses orphans with a `parentToolCallId`).
`planSteps` items are not projected yet (status quo: codex plans were
discarded).
3. **bb-injected tools carry their presentation** (Q31) —
`bb.agents.registerTool({ experimental_presentation })` (+
`docs/api_to_audit.md`); the server resolves one presentation per tool
at its boundary (declaration → status labels → generic label + the
plugin's branding glyph / `Toolbox`) onto `DynamicTool.presentation`;
the codex bridge emits calls to injected tools as `{ server: "bb", tool
}` with that presentation. ask-user-question and workflows declare
theirs (AskUserQuestion and `bb_workflow_result` collapse by default).
`HOST_DAEMON_PROTOCOL_VERSION` 149 → 150 (149 is WS2a's #2148, this PR's
base; the history reads 147 → 148 → 149 → 150). The field is optional
and `dynamicToolSchema` is not strict, so an older daemon strips it and
keeps working; the bump follows the repository rule for a widened
server↔daemon wire. Optional per A1: every committed recording's runtime
lane predates the field and must keep replaying; the stabilization pass
makes it required.
4. **Goals + macOS profile as codex extension kinds** —
`provider-codex/goal` (state; `null` once cleared) and
`provider-codex/macos-permission` (item) declared on the registration
with plugin-owned zod schemas (`extension-kinds.ts`). The bridge emits
goals as extension state. A command approval asking for macOS
capabilities now reaches the user for the command; the profile rides the
timeline as its own row saying bb cannot grant it (the plugin-rendered
approval is WS5's). **Read-time conversion in core:**
`parseStoredThreadEvent` decodes persisted `thread/goal/updated|cleared`
rows into the extension state (the one path every stored-event read
takes); thread-view goal extraction and the runtime's goal-clear wait
read that state. The sidebar's latest-goal query becomes a
latest-thread-state-by-kind query (partial index widened to
`thread/extensionState/updated`, migration **0106** `thread_state_index`
— regenerated with Drizzle so its snapshot chains from WS2a's 0105
`provider_settings_to_plugins`; `json_extract` kind filter). Verified
against the corpus: the 721-row goal thread renders its goal unchanged.
5. **Delete the codex v2 path** — `thread.goal`/`thread.goalCleared`
leave the grammar and assembler (G3 snapshot updated;
`PROVIDER_BRIDGE_PROTOCOL_VERSION` stays 2 under the grammar range, as
WS1a's v2 deletion did); the `thread/openWork` notification leaves the
protocol, adapter and reaper (an unknown notification is ignored); the
bridge drops its open-work reporting and the hard-coded AskUserQuestion
presentation. Kept: the bridge's knowledge of its spawn/resume collab
verbs for a receiver-less call — tool-name knowledge in the bridge is
the point, not a remnant. The `thread/goal/*` domain event types remain
as read-only legacy vocabulary.

Naming note: the spec says `codex/goal`; the extension-kind namespace is
the **plugin id**, which is `provider-codex`, and that is how the
registry resolves the schema.

## Regression oracle

All turbo invocations with `--concurrency 4`; perf suite ignored
(known-noisy, flagged on #2121).

- **Conformance**: codex scripted suite + recorded conformance over all
17 recorded cells (incl. archived-resume, auth-failure, empty-rollout,
missing-rollout) green.
- **Parity (A2)**, `pnpm parity --old <origin/main worktree at
f6fb434> --new . --provider codex`: **16 passed, 0 failed, 1 skipped**
(process-scoped `model-list`). Event and row counts equal in every cell.
`recordings/parity-allowlist.json` names every intended
byte-inequivalence with `#2164` and a reason — 26 entries, four classes:
`presentation` on items (15 cells), `server: "bb"` on the
AskUserQuestion call (user-question), the sub-agent spawn as a
`delegation` item + its row (`subagent`, events `/6` `/28`, rows
`/0/children/0/{toolName,output}`), and `thread/goal/cleared` →
`provider-codex/goal` extension state (one event index in each of 6
goal-bearing cells). Zero unlisted diffs, zero stale entries.
claude-code **13/13** and acp-cursor **10/10** replay against main with
**zero diffs** (no entries).
- **Corpus (A4)**, 307 threads / 93,262 rows: **zero diffs**,
claude-code and codex alike; no corpus allowlist entry was needed (the
only read-time change, goals, projects identically by design).
- **G11**: `provider/unhandled` flat on every codex cell (0→0;
auth-failure 1→1) and on the corpus.
- **G1**: 209 → 209 (no provider-id literal added or removed in core;
`"provider-codex/goal"` is not a provider-id literal by the ratchet's
regex).
- **Parity self-suite** (#2153's, in the stack): 43/43 with the codex
current lanes re-recorded against this bridge; row-count pins
**unchanged** (every current lane assembles to exactly the pinned
counts); the claude/acp current lanes #2153 produced on WS1a's bridges
hold as they are.

Tests on the final stack (all forced, `--concurrency 4`, one turbo at a
time): db 406 (migration chain 0104 → 0105 → 0106 incl.
replay-on-existing-DB cases), agent-runtime 337, codex 193 (scripted +
recorded conformance), host-daemon-contract 52, provider-parity 43;
typecheck green for codex, agent-runtime, provider-parity, server, db.
Earlier full sweep before the re-stacks: domain 150, thread-view 382,
provider-bridge-protocol 218, plugin-sdk 127, host-daemon 552,
integration 55, claude-code 262, acp 182, workflows 223,
ask-user-question 36, scripted echo 1; server 1828/1829 (the one failure
is the known local umask case in `internal-skill-trees`, passes in CI);
typecheck green across 25 packages incl. app, mobile, cli.

## Not done

- **Live QA cells via bb-dev-app**
(turn/steer/stop/approve/deny/question/subagent/resume/fork/plan with
screenshots) — not run; the coordinator schedules them separately.
- `planSteps` rows are not projected by thread-view (no regression:
codex plans were discarded before); the projection workstream owns it.
- The plugin-rendered macOS approval (the profile round-trips only as a
visible row today) — WS5.

> 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
… presentation (#2178)

Stacked on #2164 (WS1b-codex,
`bb/ws1b-codex-codex-bridge-to-v3-stack-on-2136-thr_xnid5ftd87`), which
sits on WS2a #2148 → the corpus harness #2121 → the recordings harness
#2153 → WS1a #2136 → the contract #2124 (SDK 0.4.11 at the bottom).
WS1b-claude of the provider-plugin migration: the Claude Code bridge
speaks grammar v3 with a presentation on every item, Claude's tool-name
knowledge moves out of core thread-view into the bridge, and the
bridge's last Claude-specific result structure is deleted.

**Do not merge.** Coordinator reviews, Sawyer merges the stack.

Stacked on codex rather than WS1a on purpose: codex added the minimal
thread-view projection of `delegation` items (row + nested child
content) that Claude's `Agent`/`Task` sub-agents need. The recordings +
parity harness beneath this PR is the real #2153 (in the chain, with
`pnpm rerecord` and the `PARITY_INITIALIZE_ID` fix); the one harness
file this PR touches is `redact.mjs` (bare-message ndjson, dash-encoded
home paths). The corpus harness #2121 is in the chain too; the A4 run
below is native to this tree.

## What was wrong

The Claude bridge emitted v2-shaped items: no presentation (core
thread-view kept Claude's tool-name tables — `Read`/`Grep`/`Glob`
intents, the `Agent`/`Task` delegation row, the TodoWrite/Task*
suppression list and todo reducer), `Read` as an opaque `tool` item (the
top generic tool in the production corpus: 7,568 calls), `Agent` as a
`tool` item named Agent, TodoWrite/TaskCreate/TaskUpdate as tool rows a
core reducer had to understand, bb-injected tools as
`mcp__bb-bridge__<name>` without `server`, and a structured task-tool
`result` that core parsed — and which never matched in production,
because the persisted result was the SDK's friendly string, so the task
banner did not work for the Task tools.

## What changed (one commit per layer; the last deletes)

0. **Transcript → SDK-stream converter and fixtures** (the spec's first
task). `scripts/provider-recordings/convert-claude-transcript.mjs` turns
a `~/.claude/projects` session (plus its
`<session>/subagents/agent-*.jsonl` sidechains, interleaved by timestamp
with `parent_tool_use_id` from the subagent's `toolUseId`) into the SDK
stream the bridge would have seen. A transcript has no `result`, no
`system/init` and no `task_*` family, so the converter synthesizes them
deterministically: a `result` per segment (a root message that stops
with a non-tool-use reason, the next prompt, EOF),
`task_started`/`task_updated`/`task_notification` for Agent calls from
the call, its result (`async_launched` ⇒ backgrounded) and the
`<task-notification>` resume,
`api_retry`/model-fallback/`compact_boundary` from the system records.
Human prompts only delimit turns — a live stream never echoes them
(verified against every committed recording); CLI-injected user messages
(isMeta context, task notifications) do stream and are kept.
`convert-claude-transcripts-sample.sh` rebuilds the committed sample:
**12 sessions/windows, 1,076 messages (331 sidechain records), 1.85 MB
after redaction**, all from the owner's own corpus threads: plan mode +
AskUserQuestion + ExitPlanMode, WebSearch, WebFetch + Read, Edit/Write,
foreground and backgrounded Agents with their sidechains,
TaskCreate/TaskUpdate + `model_refusal_fallback`, Workflow + Monitor +
TaskStop, TaskOutput, ScheduleWakeup, SendMessage, `api_retry`,
`compact_boundary`, `mcp__bb-bridge__` tools. Grep, Glob, TodoWrite,
MultiEdit and NotebookEdit appear in none of the 2,559 local
transcripts; those paths are covered by scripted unit tests.
`transcript-fixtures.test.ts` drives each fixture through the
`sdk/message` envelope into a real assembler and checks structural
invariants (every tool_use opens an item its tool_result settles,
sidechain items nest under the spawning call, every turn settles,
nothing left open, every started item presented) plus a pinned
projection per fixture in `expected.json` (item kinds, tool names, plan
snapshots, `provider/unhandled`, which may only go down).
1. **Presentation on every item; the v3 kinds** —
`plugins/provider-claude-code/src/presentation.ts` is the one place
Claude's tool-name knowledge lives; `tool-classification.ts` maps every
tool_use to its shape with that presentation. `Read` → `fileRead`;
`Grep` → `search{content}`, `Glob` → `search{path}`;
`Edit`/`Write`/`MultiEdit`/`NotebookEdit` → `fileChange` with per-verb
labels; `Bash` → `command` (a backgrounded call is labelled as a
launch); `WebSearch`/`WebFetch` as before with presentation.
`Agent`/`Task` → `delegation` (`childRef` = the call id, which is how
the SDK identifies the sub-agent's stream: `parent_tool_use_id`;
`background: true` for `run_in_background`, settling at the launch ack
on the thread-scoped family; summary = the result text without Claude's
`agentId:`/`<usage>` lines; sub-agent type and model in the presentation
detail). `TodoWrite` → a collapsed call row plus a settled `planSteps`
snapshot from its arguments;
`TaskCreate`/`TaskUpdate`/`TaskList`/`TaskGet` → a collapsed call row
plus a `planSteps` snapshot of the thread's folded task list
(`plan-fold.ts`, reading the SDK's envelope-level `tool_use_result`)
after each successful call — channel-keyed close deltas, the latest
superseding, the same shape as codex `update_plan`. `ToolSearch`,
`TaskOutput`, `Monitor`, `ScheduleWakeup`, `SendMessage`,
`AskUserQuestion`, `TodoRead`, `BashOutput` → `tool` with
`presentation.suppress`; plan mode, Workflow, TaskStop, Skill,
StructuredOutput, worktrees, ListAgents → `tool` with their own
labels/glyphs/titles; an unknown tool reads `Running <tool>`/`Ran
<tool>`. `mcp__<server>__<tool>` splits into `{ server, tool }`. The
compaction item and every close-without-open fallback carry one too; the
close re-states the open's. **Thread-view keeps the new kinds
rendering** until the presentation-driven projection lands (the same
minimal bridge codex added for `delegation`): `fileRead`/`search` items
project to the tool row with the intents the legacy Read/Grep/Glob calls
produced (tested equal to the legacy rows); a tool call whose
presentation says `suppress` is hidden like the legacy name list
(failures still render); the todo banner reads a `planSteps` snapshot
as-is. No persisted-event projection changed: the legacy tables stay for
old rows (G1 unchanged).
2. **Background tasks carry their presentation** — workflows,
backgrounded shells and backgrounded sub-agents stay the core
`backgroundTask` kind (genericity rule) and say how they read on
open/close. Model fallback and `/clear` stay the core events they are.
3. **bb-injected tools** (Q31) — a `mcp__bb-bridge__<name>` call is `{
server: "bb", tool: <bare name> }` with the presentation the server
resolved onto the `DynamicTool` definition, learned through
`configureInjectedTools` at session construction; a definition without
one presents generically under bb's glyph. The server's `statusLabels`
enrichment skips items with a server, so nothing relies on it.
4. **Delete the Claude v2 translation path** — the task tools'
structured `result` (the shape core's legacy todo reducer read) is gone;
the tool row carries the text result like every tool and `planSteps` is
the one structured form core sees. The bridge no longer imports the
SDK's claude task-tool schemas. Kept on purpose: the bridge's knowledge
of its own tool names, and the close-without-open fallback.

`HOST_DAEMON_PROTOCOL_VERSION` stays **150**: nothing on the
server↔daemon wire changed (translation and an item-shape change inside
the `thread/delta` lane only).

## Regression oracle

All turbo invocations with `--concurrency 4`; perf suite ignored
(known-noisy, flagged on #2121). Old leg: an `origin/main` worktree at
`f6fb434ab`.

- **Conformance**: claude scripted suite + recorded conformance over all
14 recorded cells (incl. auth-failure, plan-mode, subagent,
user-question) green. The claude `bridge→runtime.current.ndjson` lanes
are re-recorded with this bridge (`pnpm rerecord --plan-with <main
checkout> --provider claude-code`); the recordings themselves are
untouched and the self-suite's row-count pins are **unchanged** (43/43).
- **Parity (A2)**, `pnpm parity --old <origin/main worktree> --new .
--provider claude-code`: **13 passed, 0 failed, 1 skipped**
(process-scoped `model-list`). Event and row counts equal in every cell.
`recordings/parity-allowlist.json` gains 27 entries naming `#2178`,
three classes:
- `presentation` on items — `/*/item/presentation`, events, 9 cells
(approval-allow, approval-deny, compaction, plan-mode, steer, subagent,
turn-tools, user-question, web-search; the other 4 cells have no items).
- Read → `fileRead` — plan-mode events `/2`, `/4`, `/24`, `/25`; rows
`/0/children/{0,5}/{toolName, toolArgs, output,
activityIntents/0/command, activityIntents/0/name}` (the row projects
from a fileRead item: no tool name or arguments, the file contents are
not row data, the intent command is `Read <path>`).
- Agent → `delegation` — subagent events `/6`, `/13`; rows
`/0/children/0/toolName` ("delegation") and `/0/children/0/subagentType`
(the delegation item has no such field; the type rides the presentation
detail).
Zero unlisted diffs, zero stale entries. codex **16/16** and acp-cursor
**10/10** replay against main with **zero new diffs** (no entries added;
nothing of theirs touched). The expected "suppressed low-value rows" and
"planSteps" classes produce no parity diff: no recorded cell calls
Monitor/TaskOutput/ScheduleWakeup/SendMessage or TodoWrite/Task*, and
ToolSearch/AskUserQuestion were already hidden by name.
- **Corpus (A4)**, 307 threads / 93,262 rows: **zero diffs**,
claude-code and codex alike, no corpus allowlist entry needed. Persisted
Claude events are `toolCall` items without presentation, and every
thread-view change here applies only to the v3 kinds and to
`presentation.suppress` — the legacy tables and reducer are untouched —
so old rows project identically by construction.
- **G11**: `provider/unhandled` flat on every claude cell (0→0;
compaction and steer 2→2) and on the corpus; pinned per transcript
fixture (23 across 12 fixtures, every one a CLI-injected string-content
`user` message — compaction summaries, `<task-notification>` resumes —
which visibility classifies `unknown` today; lowering that is a separate
change).
- **G1**: unchanged (no provider-id literal added or removed in core;
the legacy Claude tool-name tables stay for persisted rows).

Tests (forced): thread-view 385, claude-code 322, codex 193,
provider-bridge-protocol 218, provider-parity 43; server 1832/1833 (the
one failure is the known local umask case in `internal-skill-trees`,
which passes in CI). Typecheck green for the claude plugin, thread-view,
server and agent-runtime on the re-stacked base.

## Not done

- **Live QA cells via bb-dev-app**
(turn/steer/stop/approve/deny/question/subagent/resume/fork/plan with
screenshots) — not run; the recorded cells, the transcript fixtures and
parity were the oracle.
- `planSteps` rows are not projected as timeline rows (status quo:
TodoWrite/Task* rows were hidden); the banner reads them. The
presentation-driven projection workstream owns the rows.
- The delegation row loses the "(Explore)" sub-agent-type suffix for new
Claude sub-agent rows until the projection reads the presentation detail
(allowlisted; old rows unaffected).
- Backgrounded `Agent` calls keep today's two-row structure (the
delegation settles at the launch ack; the `local_agent` background task
tracks the work). Folding the task into a single background delegation
is a larger change to the runtime's open-work tracking and the
background-commands card.
- String-content `sdk/user` messages (task-notification resumes,
compaction summaries) still surface as `provider/unhandled`; the
fixtures show they are the whole G11 residue for Claude. A one-line
visibility change would lower it but changes parity for the
steer/compaction cells, so it is left for a deliberate follow-up.

> 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
The provider-plugin v3 stack (#2124#2179) landed on main and deleted 61
provider-id references from core: the reserved-id tables, the three Claude
model tables, the placeholder provider infos, the claude/codex settings
knobs on the shared contract, and the per-provider runtime branches the
registry and bridges now own. Regenerated baseline: 148 references across
40 core files (from 209 / 55). The ratchet holds it there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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
…2169)

## What was wrong

Every thread lifecycle transition broadcast `status-changed` as a bare
dirty flag (metadata carried at most `projectId`). The app's registry
rule for it (`dirtyActiveThreadListQueries`, flush `immediate`)
invalidated the single `sidebarNavigation` query plus every cached
thread list for the project. The sidebar query is always active
(`AppLayout` observes it with `staleTime: Infinity`), so each push
re-downloaded the whole `GET /api/v1/sidebar-bootstrap` document: about
1 KB per unarchived thread, 134 KB on the seeded database, twice per
turn (turn start, turn end), for every thread that runs a turn. Nothing
in the push let the client patch the one row that changed. Issue: #1302.
Report: https://get-bb.github.io/reports/issues/1302.html

## What changed

Server-to-app realtime contract (no daemon change; the host daemon does
not consume thread change notifications, so
`HOST_DAEMON_PROTOCOL_VERSION` is unchanged):

- `packages/domain/src/change-kinds.ts`: `threadChangeMetadataSchema`
gains optional `statusChange: { status, runtime, activity,
latestAttentionAt, updatedAt }` (the list-row fields a lifecycle
transition rewrites). The lenient inbound twin parses it with
`.catch(undefined)` so a client that does not know a future status or
runtime value drops just that field and falls back to a refetch.
`threadRuntimeStateSchema` and `threadActivityStateSchema` are now
exported from `thread.ts`.
- `apps/server/src/services/threads/thread-runtime-display.ts`:
`buildThreadStatusChangeMetadata(deps, thread)` builds the metadata in
one place. Runtime is resolved from host connectivity the same way list
rows do. Activity (background task counts plus the plan-mode and goal
counts) is built by the new `buildThreadActivityStateByThreadId`, which
`toThreadListEntryResponses` now also uses, so a pushed row and a
fetched row cannot disagree. The builder therefore takes the
prompt-banner deps (`db`, `hub`, `providerRegistry`); every caller
already had them through `AppDeps`/`WorkSessionDeps`
(`failThreadProvisioning` and `applyTurnCompletedEvent` widen their
`Pick`).
- `packages/db/src/data/threads.ts`: `applyThreadLifecycleEvent(db,
args)` no longer takes a notifier or notifies. The db package cannot
resolve the runtime (host connectivity lives in the hub), so the server
wrapper `applyLoggedThreadLifecycleEvent` (`lifecycle-outcome.ts`) now
owns the `status-changed` push and attaches the metadata. This covers
turn start (`run.started` from the daemon's `turn/started`), turn end
(`turn/completed`), provisioning, reconciliation and failure paths.
- `thread-send.ts`, `queued-messages.ts`, `parent-system-messages.ts`:
the three post-commit producers that activate a thread now carry the
activated row out of the transaction (`activeThread: Thread | null`
replaces `threadBecameActive: boolean`) and attach the metadata.
`queued-messages.ts` also drops a redundant `status-changed` notify that
fired inside the transaction, before commit; the post-commit notify on
the next line already sent the same kind.
- `packages/domain/src/plugin-sdk-version.ts` +
`packages/plugin-sdk/package.json`: no longer changed by this PR. The
new `statusChange` field does change the SDK's bundled types and
`dist/provider-bridge.js`, 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
without a further bump.
- In-transaction writers whose hub is a `NotificationBuffer` (stop
requested, command failure, thread-start success, finalize, host-wide
interruption, environment cleanup, host reconnect fan-out) still send
the bare kind. The client falls back to today's refetch for those; they
are not on the per-turn hot path.
- `apps/app`: `realtime-cache-effects.ts` merges `statusChange` into the
dirty context. A `status-changed` message is last-writer-wins for it: a
later message that carries no row snapshot replaces (drops) an earlier
one merged while the document was hidden, so the resume flush refetches
instead of patching the row to the earlier, now-stale status.
`realtime-cache-registry.ts` replaces `dirtyActiveThreadListQueries` in
the `status-changed` rule with `patchThreadListStatusState`: with
metadata it writes the five fields into every cached thread list row and
the sidebar bootstrap (`updateCachedThreadListStatusState` in
`query-cache.ts`, same shape as the existing pending-interaction patch),
invalidates only list/sidebar queries that have a fetch in flight (that
fetch read the database before the transition and would overwrite the
patch when it lands), and still dirties the search prefix. Without
metadata it behaves exactly as before. Thread detail invalidation is
unchanged (about 600 B when the thread is open).

Revision after review (two findings, both fixed here):

1. The first draft's patch left `ThreadListEntry.activity` stale. The
plan-mode and goal counts are server-computed, gated on `status ===
"active"`, and were only synced by the list refetch the patch removed,
so a finished plan turn kept its sidebar indicator lit. The push now
carries the post-transition activity and the app patches it with the
rest of the row.
2. The hidden-document merge kept an earlier `statusChange` when a later
bare `status-changed` arrived (stop, command failure, interruption), so
on resume the row was patched to `active` and never refetched.
`statusChange` is now last-writer-wins per `status-changed` message.

Deviation from the report's proposal: the report suggested `status` +
`runtime` only. `latestAttentionAt` and `updatedAt` are included because
the lifecycle writer rewrites them and the sidebar sorts inactive rows
by `latestAttentionAt`; `activity` for the reason above. The report's
parts 2 (trim the bootstrap wire shape) and 3 (per-project sidebar keys)
are not in this PR; with no refetch per turn, the payload size only
matters on initial load and on membership changes.

Known, pre-existing: the sidebar learns that a plan turn is active only
from a list row fetched after the provider's `turn/input/accepted`
lands. The turn-start push (and on `main`, the turn-start refetch) is
built at send time, before that event exists, and `events-appended` does
not refetch lists, so the plan-mode glyph at turn start was already a
race on `main`. This PR keeps that behavior and fixes the indicator
turning off at turn end. Pushing an activity patch on the accepted/goal
events is a separate follow-up.

## How you verified

Tests added:

- `apps/app/src/hooks/realtime-cache-effects.test.ts`: "patches cached
thread list status from notification metadata instead of refetching the
sidebar bootstrap". Fails on `origin/main` app sources with
`AssertionError: expected "vi.fn()" to be called 1 times, but got 2
times` (the sidebar query fn was refetched); passes after. The pushed
`statusChange` in this test carries `activity.activePlanModeCount: 1`
and the row assertion covers it. Also: "refetches thread lists for a
status change that carries no row metadata" (guards the fallback),
"restarts a sidebar fetch already in flight so its stale snapshot cannot
overwrite the patched status", and (hidden document) "refetches when a
bare status-changed follows one that carried the row". The last fails on
the first draft's merge with `AssertionError: expected { activity: {
…(5) }, …(5) } to be { activity: { …(5) }, …(5) } // Object.is equality`
(the idle row had been replaced by the patched active row); passes
after.
- `apps/server/test/services/threads/lifecycle-outcome.test.ts` (new):
`applyLoggedThreadLifecycleEvent` broadcasts `status-changed` with
`projectId` and the full `statusChange` (runtime `active` with a
registered daemon, `waiting-for-host` without), nothing when the event
is not applied, and "carries the status-gated plan and goal activity of
the post-transition row": with an open accepted `/plan` turn and an
active goal on record, `run.started` pushes `activePlanModeCount: 1,
activeGoalCount: 1` and `run.succeeded` pushes `status: idle` with
`activePlanModeCount: 0, activeGoalCount: 1`. On the first draft's
server builder the broadcast is rejected by the strict schema
(`ZodError: Invalid input: expected object, received undefined` for
`activity`); on `main` the first assertion fails because the db notify
carried only `projectId`.
- `packages/domain/test/change-kinds.test.ts`: maximal fixture extended
(the parity guard requires it) plus "drops a status change a stale
client cannot parse but keeps the message".
- `packages/db/test/data/thread-lifecycle.test.ts`: the notify assertion
moved to the server test; call sites updated for the new signature (also
`tests/integration/fake/recovery/idle-error-reconciliation.test.ts`,
which polls the API and does not depend on the push; ran it, passes).

Commands (on the committed tree, rebased on current `origin/main`, `git
status --porcelain` empty):

- `pnpm exec turbo run typecheck` (whole repo): `Tasks: 72 successful,
72 total`.
- `pnpm exec turbo run test --filter=@bb/domain --filter=@bb/db
--filter=@bb/server --filter=@bb/app --continue`: `Tasks: 9 successful,
10 total`. domain 136 passed; db 405 passed; app 3160 passed (3
skipped); server 1825 passed, 2 failed:
`test/internal/internal-skill-trees.test.ts` (`mode: 420` vs `436`, a
local umask 0002 artifact unrelated to this change, passes with `umask
022`) and `test/services/plugins/plugin-update.test.ts` "waits one full
interval" (`Test timed out in 5000ms` under the full parallel run;
passes alone, 27/27).

Manual check on my dev instance (scratch project, one codex thread, a
dedicated headless Chromium profile with a `window.fetch` logger
installed after load, then `POST /api/v1/threads/:id/send` with a `/plan
Reply only with ok.` command mention from the driving script, sampling
the sidebar row's indicator labels every 100 ms):

- Before (report, same experiment with plain `tell`): 19 requests, 2 ×
`GET /api/v1/sidebar-bootstrap` at 134,865 B and 134,861 B (96% of
bytes), plus child/fork list refetches at turn start and end.
- After: 10 requests, **0** `sidebar-bootstrap` calls, no thread list
refetches; the turn traffic is the thread detail (588/584 B), timeline
deltas, outline, prompt history, PR state and read receipt. The sidebar
row showed `Thread working` 123 ms after the send and cleared it at turn
end (2.3 s), from the pushed patch alone. At 1.5 s `GET
/threads?projectId=` reported `status: active, activePlanModeCount: 1`;
after the turn the row carried no stale plan indicator. Log saved at
`/tmp/bb-fix-batch/issues/1302/revise-plan-turn-api-log.json`.

Fixes #1302

> AGENT GENERATED: by Claude Opus 5


## Independent verification

Verified round 2 at head `1e1e56ff7` (rebased on `origin/main`
`c942421a4`; `git merge-base --is-ancestor origin/main HEAD` true,
GitHub reports MERGEABLE) in a fresh worktree.

Commands:

- `git fetch origin main && git fetch origin
bb/fix-1302-sidebar-bootstrap && git checkout -b verify-1302-r2
FETCH_HEAD`; `pnpm install --frozen-lockfile --prefer-offline`; `pnpm
exec turbo run build`.
- Fail-before: `git checkout origin/main -- <13 non-test source files>`
then `pnpm exec vitest run src/hooks/realtime-cache-effects.test.ts`
(apps/app): 1 failed / 56 passed, `AssertionError: expected "vi.fn()" to
be called 1 times, but got 2 times` ("patches cached thread list status
from notification metadata instead of refetching the sidebar
bootstrap"). `pnpm exec vitest run
test/services/threads/lifecycle-outcome.test.ts` (apps/server): 3 failed
/ 1 passed; the broadcast metadata was `{ projectId }` only (`-
"statusChange": { … }` in the assertion diff), `expected undefined to be
'waiting-for-host'`, `expected undefined to deeply equal {
activeGoalCount: 1, activePlanModeCount: 1, … }`.
- Revision check: with the first draft's `realtime-cache-effects.ts`
(`fdfaec771`) checked out, `-t "bare status-changed follows"` fails with
`AssertionError: expected { activity: { …(5) }, …(5) } to be { activity:
{ …(5) }, …(5) } // Object.is equality`.
- Pass-after (`git checkout HEAD -- …`, tree clean): app file 57/57,
server file 4/4.
- `pnpm exec turbo run typecheck --filter=@bb/domain --filter=@bb/db
--filter=@bb/server --filter=@bb/app --filter=@bb/integration-tests
--filter=@bb/mobile --filter=@bb/sdk --filter=@bb/desktop`: `Tasks: 12
successful, 12 total`.
- `pnpm exec turbo run test --filter=@bb/domain --filter=@bb/db
--filter=@bb/server --filter=@bb/app --continue`: domain 136, db 405,
app 3160 (3 skipped) passed; server 1826 passed / 1 failed =
`test/internal/internal-skill-trees.test.ts` (`mode: 420` vs `436`, the
known local umask 0002 artifact; passes in CI).
- CI on the PR: all checks pass (Checks, Package Smoke x2, Tests
app-1/2/3, integration, packages, server).

Repro on the fixed branch (own dev instance :18681/:26681/:34681,
scratch project, one codex thread, headless Chromium with a
`window.fetch` logger installed after load, `pnpm bb:dev thread tell
<id> "Reply only with ok."` from the shell, 100 ms DOM poll of the
sidebar row): three sends, each 9-10 API requests and **0** `GET
/api/v1/sidebar-bootstrap` (report on main: 19 requests, 2 bootstrap
downloads = 96% of bytes). The sidebar row showed `Thread working` about
100 ms after the send, `Unread thread succeeded` at turn end and cleared
after the read receipt, all from the pushed patch. No longer reproduces.

Review notes: server-to-app contract only; the host daemon does not
consume thread `changed` messages, so no `HOST_DAEMON_PROTOCOL_VERSION`
bump is needed; every inbound consumer (app, mobile, desktop, sdk) uses
the lenient schema; thread lists are ordered by pin/createdAt and not
filtered on status, so patching cannot change membership; all
`applyThreadLifecycleEvent` callers updated. Residual (documented in the
body): in-transaction producers (stop, command failure, thread-start
success, interruption, env cleanup, host reconnect) still push the bare
kind and refetch the whole bootstrap; the plan-mode glyph at turn start
stays a pre-existing race; the 138 KB payload shape and single sidebar
key (report parts 2 and 3) are untouched, so a reviewer may prefer to
keep #1302 open for the payload trim. Nit: `thread-runtime-display.ts`
L268-270 is not prettier-formatted (CI does not enforce it).

> AGENT GENERATED: by Claude Opus 5


## Rebase

Rebased onto `origin/main` `75d6fc4d4` (was 32 commits behind at
`c942421a4`) and squashed the two commits into one (`766f1928f`); the
commit message keeps the original subject and body and folds in the
revision-round notes (activity on the push, last-writer-wins merge).
`git rebase` applied cleanly with no conflicts: none of the 32 commits
on main touched the 23 files in this diff. The commits on main in the
neighbouring areas (`apps/server/src/services/threads`,
`packages/domain/src`, `apps/app/src/hooks`) are the provider v3
contract stack (#2124, #2136, #2148, #2164, #2179), the late tool-call
completion fix (#2176) and the acp fork capability change (#2150); they
do not touch the lifecycle writer, the thread change-kind schema, or the
realtime cache registry, so the fix maps onto the new base unchanged.
`origin/main` still has no `statusChange` in `change-kinds.ts` or
`realtime-cache-registry.ts`.

Re-proved on the new base (committed tree, `git status --porcelain`
empty):

- Fail-before: with the 13 non-test source files checked out from
`origin/main`, `apps/app` `realtime-cache-effects.test.ts`: 1 failed /
56 passed, `AssertionError: expected "vi.fn()" to be called 1 times, but
got 2 times`; `apps/server` `lifecycle-outcome.test.ts`: 3 failed / 1
passed (`expected undefined to be 'waiting-for-host'`, `expected
undefined to deeply equal { Object (activeBackgroundAgentCount, ...)
}`). Pass-after: 57/57 and 4/4.
- `pnpm exec turbo run typecheck --filter=@bb/domain --filter=@bb/db
--filter=@bb/server --filter=@bb/app --filter=@bb/integration-tests
--filter=@bb/mobile --filter=@bb/sdk --filter=@bb/desktop
--filter=@bb/host-daemon --filter=@bb/cli`: `Tasks: 14 successful, 14
total`.
- `pnpm exec turbo run test --filter=@bb/domain --filter=@bb/db
--filter=@bb/server --filter=@bb/app --continue`: domain 27/27 files, db
28/28 files; server 1899 passed / 2 failed; app 3190 passed / 3 failed
(3 skipped). The machine was under a load average of 40-70 from parallel
agents: every app failure and the `plugin-update.test.ts` server failure
were `Test timed out` in files unrelated to this change (different files
on each of two runs), and each passes when rerun alone (135/135, 27/27).
The one remaining server failure is
`test/internal/internal-skill-trees.test.ts` (`mode: 420` vs `436`), the
known local umask 0002 artifact that passes in CI.

> AGENT GENERATED: by Claude Opus 5




## Independent verification (guards)

Verified head `17746125f` (rebased onto `origin/main` `27d1017fe`; `git
merge-base --is-ancestor origin/main HEAD` true; GitHub reports
`MERGEABLE` / `CLEAN`) in a fresh worktree. Scope: confirm the
post-verification change is only the CI-guard fix, re-prove
fail-before/pass-after on the new head, confirm CI.

- Interdiff: `git diff 75d6fc4 766f192` (previously verified patch)
vs `git diff origin/main 1774612` differ by exactly two hunks:
`packages/domain/src/plugin-sdk-version.ts` `PLUGIN_SDK_VERSION =
"0.4.11"` → `"0.4.12"` and `packages/plugin-sdk/package.json`
`"version": "0.4.11"` → `"0.4.12"`. No other line of the PR changed.
`origin/main` and `npm view @get-bb/plugin-sdk version` are both
`0.4.11`; `@get-bb/plugin-sdk@0.4.12` is 404 on npm, so the patch bump
targets the next unpublished version. The commit keeps the original
subject, body, and `Co-Authored-By` trailer.
- CI on `17746125f`: all checks pass (Checks, Package Smoke x2, Tests
app-1/2/3, integration, packages, server, Version Lockstep x2; Node
Compatibility Smoke and iOS simulator flows skipped by design). The
`Check plugin SDK npm version guard` step logs `npm version guard: PASS
— @get-bb/plugin-sdk@0.4.12 is not on npm yet. The publish job will ship
this version.`
- Fail-before on the new head (`git checkout origin/main -- <15 non-test
source files>`): `packages/domain` `change-kinds.test.ts` 2 failed / 6
passed (`ZodError` on the maximal strict `thread` fixture, `expected [
'backgroundActivityChanged', …(4) ] to deeply equal [ …(3) ]`);
`apps/server` `lifecycle-outcome.test.ts` 3 failed / 1 passed (assertion
diff shows the broadcast `metadata` is `{ projectId }` only, `-
"statusChange": { activity, latestAttentionAt, runtime, status,
updatedAt }`); `apps/app` `realtime-cache-effects.test.ts` +
`cache-owner-registry.test.ts` 2 failed / 59 passed (`AssertionError:
expected "vi.fn()" to be called 1 times, but got 2 times` at
`realtime-cache-effects.test.ts:1905`).
- Pass-after (`git checkout HEAD -- …`, `git status --porcelain` empty):
domain 8/8, server 4/4, app 61/61. `pnpm exec turbo run typecheck
--filter=@bb/domain --filter=@get-bb/plugin-sdk`: `Tasks: 5 successful,
5 total`. `pnpm exec turbo run build`: `Tasks: 18 successful, 18 total`.
- Repro on the fixed branch: not re-run this round; the fix code is
byte-identical to the head whose browser repro (0 `GET
/api/v1/sidebar-bootstrap` per send) is recorded above.

Residual risks unchanged from the sections above. This PR no longer
carries an SDK version change; `main`'s unpublished `0.4.13` covers it.


## Rebase (2026-08-21)

Rebased onto `main` at `d41d1abee`. Only
`packages/domain/src/plugin-sdk-version.ts` and
`packages/plugin-sdk/package.json` conflicted, because `main` moved the
SDK from `0.4.12` to `0.4.13`. Both were resolved to `main`'s values, so
the version files have dropped out of this PR's diff entirely (25
changed files -> 23). No other line of the fix changed.

Re-verified on the new base: `node
packages/plugin-sdk/scripts/check-npm-version-guard.mjs` -> `PASS -
@get-bb/plugin-sdk@0.4.13 is not on npm yet`. `pnpm exec turbo run
typecheck --filter=@bb/app --filter=@bb/server --filter=@bb/db
--filter=@bb/domain --filter=@bb/integration-tests`: `Tasks: 9
successful, 9 total`. `pnpm exec turbo run test` for
app/server/db/domain: domain 27/27 files, db 28/28, server pass;
`@bb/app` reported one failure in `PromptBoxInternal.test.tsx >
selection reveal`, which passes on its own re-run and touches no file in
this PR (the app changes are confined to `src/hooks/cache-owners/`).
Treated as load-dependent flake; CI is the arbiter.

> 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