[stacked on #2124] WS1a: generic assembler + published testing kit - #2136
Conversation
|
Coordinator review — APPROVE on the code, with one explicit condition (SlopCop disabled; this review is the gate). Reviewed all 8 commits (+8378/−9096, a genuine deletion layer):
The condition — the regression oracle did not run, and this is the layer it exists for. Layer 4 deliberately breaks byte-equivalence; corpus row snapshots (A4, #2121) and parity replay (A2) are the designed checks for that, but neither is in this stack's base (recordings is deferred; baselines is a sibling). Goldens + conformance are real evidence, but they are not the 307-thread production-data check. Before this merges: rebase onto Handoff noted for WS2a: runtime skill roots are still typed by first-party provider id, so a scripted/third-party provider gets none — that's the Sawyer merges the stack; I do not.
|
|
Merge condition resolved — A4 corpus regression check run by the coordinator. Built a throwaway worktree of this PR's head ( Row snapshots: PASS. 307/307 threads, 328 tests green, zero row diffs against the pre-migration baseline, with an empty allowlist. The dialect collapse (layer 4) and the v2 deletion (layer 7) produce byte-identical timeline rows on real production data. That is the oracle this layer needed. Perf: inconclusive, not a regression. 17/20 largest threads land in 0.82–1.10× of baseline (several faster, consistent with the ~4% assembler micro-bench win); 3/20 tripped the 1.10 gate narrowly (1.13×, 1.22×, 1.14×). The harness self-flagged the run: "load average 18.5 on 16 cores — OVERSUBSCRIBED, timings are not trustworthy" — a sibling workstream was building concurrently. There is no systematic slowdown signature (a real assembler regression would shift all 20). I'll rerun only the perf suite on a quiet box and post the result; it does not block the row-level verdict. Log retained in thread storage (
|
|
Perf follow-up — quiet-box rerun: not a regression. Ran only the perf suite on an idle machine (load 11.6; the suite itself drives load).
Verdict stands: rows byte-identical on 307 production threads, no perf regression. Merge condition fully resolved. (Separately, this surfaced that the
|
da23b84 to
19e4534
Compare
… 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.
… 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.
… 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.
…r range to [2, 3] fileRead, search and planSteps open pending and settle from the terminal shape like command does. A foreground delegation settles through the turn-scoped item/completed; a background delegation is thread-attached like a background task: its item.progress snapshots ride item/delegation/progress, its close rides item/delegation/completed, neither needs an open turn, and turn settlement and session.ended leave it open. A bare terminal delegation shape keeps the summary the opened item (or its last progress snapshot) carried. ASSEMBLER_GRAMMAR_VERSIONS widens to [2, 3] so a v3-capable bridge negotiates 3. Extension kinds still throw UnsupportedDeltaShapeError until the ingest validation layer lands. Co-Authored-By: Claude <noreply@anthropic.com>
The assembler builds the canonical extension item from an extension shape (opaque payload, the lifecycle delta's mandatory presentation) and emits extension.state as a new thread-scoped thread/extensionState/updated event (latest snapshot per kind wins downstream). UnsupportedDeltaShapeError is gone: every grammar v3 shape assembles. The server is the one place an extension payload is checked. Provider registrations now carry the Standard Schema validators the plugin declared (experimental_extensionKinds), the registry resolves a namespaced kind through its plugin-id prefix, and the daemon event route validates every extension item and state payload before persisting it. An undeclared kind, a schema miss, a validator error, or a payload over 64 KiB is persisted as provider/unhandled in the same batch slot — visible and counted under the thread's provider, never dropped and never stored unvalidated. extensionKindSchema now parses to the ExtensionKind type so consumers can split it without re-checking the namespace. Co-Authored-By: Claude <noreply@anthropic.com>
Streaming: every text stream is now an item keyed like every other item. item.textDelta keys a stream by the provider's own item id when it names message items (codex) or by a bridge-chosen key.channel plus parentRef for anonymous streams (claude, pi, acp); the assembler accumulates the stream per open item, and the new item.textClose settles it with a provider-final text or the accumulated one (whitespace-only completes nothing) and releases the key. A tool item.open releases the anonymous assistant stream in its scope; provider-named items keep their own lifecycle. session.ended settles a streamed item with the text it received so far. Usage: the new usage delta carries total, last and modelContextWindow and is forwarded verbatim as thread/tokenUsage/updated. Codex forwards its exact totals and sends the context meter as a contextWindow delta beside it (contextWindow gains providerTurnId for that); claude and pi sum per- turn usage into the session total with the bridge kit's addTokenUsage, resetting where they send session.reset (pi's translator gains a per- thread resetThread for that). The assembler no longer fans usage out or accumulates it. All four bridges and the echo example emit the unified dialect; the v2 kinds (message.delta, message.close, usage.turn, usage.exact) stay accepted until the deletion layer. The G3 grammar snapshot records the two new kinds and the one new optional field; the protocol version stays 2. Intended byte-level differences vs the v2 paths (everything else is identical, which the bridge translation suites and calibration goldens pin): a provider-named text item that streamed before session.ended now settles with its accumulated text instead of its opened (empty) shape. Co-Authored-By: Claude <noreply@anthropic.com>
The bridge-protocol adapter decodes a provider/recovery notification with the protocol schema (a malformed one is dropped like any malformed notification) and the runtime forwards the typed hint, stamped with its provider id, to a new onProviderRecovery option instead of translating it: a recovery hint is a runtime signal, never a timeline event. The host daemon logs forwarded hints. No kind is acted on yet — the unarchive-and- retry, bridge restart, stale-steer drop and retry-scheduling actions land with the runtime cleanup workstream. Grammar negotiation itself shipped in the contract PR; the assembler's range was widened to [2, 3] in the first layer of this stack. Co-Authored-By: Claude <noreply@anthropic.com>
…r-bridge/testing The delta assembler moves from @bb/agent-runtime into @bb/provider-bridge-protocol (new assembler subpath), beside the grammar it assembles and the conformance kit that checks it; the runtime imports it from there. It had to move: the SDK cannot depend on the runtime (the runtime already dev-depends on the SDK, and turbo refuses package cycles), and a bridge author needs the real assembler — not a re- implementation — to see the canonical events the runtime would build. The new SDK entry re-exports, with the experimental_ prefix every new plugin API member ships with: the conformance kit, createDeltaAssembler, the delta→event collector (moved from the runtime's test dir into the protocol's testing dir), the JSON-RPC harness, and the calibration normalizer. The JSON-RPC stdout capture now patches process.stdout.write itself instead of vi.spyOn, so the published kit imports no test runner. The echo example imports only @get-bb/plugin-sdk/provider-bridge and the new testing entry (its @bb/* devDependencies are gone), and every first-party bridge suite does the same — zero first-party privilege for the testing surface. docs/api_to_audit.md gains the entry; the G10 doc- sync test asserts the entry exists. Co-Authored-By: Claude <noreply@anthropic.com>
tests/scripted-echo-provider is the echo example bridge plus the scripted directives the suites drive (delay, approvals, user questions, tool calls) and the session/process behaviour they script (archived sessions, failing commands, crashes, slow starts) through providerOptions.scripted or the SCRIPTED_ECHO_OPTIONS env JSON; SCRIPTED_ECHO_RECORD_PATH records every request it handles. It passes the canonical conformance suite. The integration harness no longer has an adapterFactory seam: the fake providers are declarations backed by the scripted echo artifact, built exactly as the plugin runtime builds a real provider plugin, and the daemon runs it through the bridge-protocol adapter and delta assembler. The runtime-config smoke test reads the bridge's request record instead of intercepting adapter commands in-process; it and the fan-out tests create threads on the fake ids, since a first-party id now launches that provider's real bridge. The continuity test asserts distinct bb-minted message ids across a daemon restart (#1224) instead of the legacy fake's reused msg-1. The fake ProviderAdapter and its script are unused by the integration suites from here; the runtime unit suites move off them in the deletion layer. Co-Authored-By: Claude <noreply@anthropic.com>
…lta dialects The top layer of the WS1a stack: everything that still existed only to keep the pre-bridge world alive goes. Runtime: the ProviderAdapter interface and its factory seam (adapterFactory, createAgentRuntimeWithAdapters, ProviderAdapterFactoryOptions) are gone; the bridge-protocol adapter is the one adapter and is typed as a concrete BridgeProtocolAdapter contract. The optional members no adapter implemented (normalizeExecutionOptions, clearActiveTurnState, buildThreadDetachedEvents) and the noop-plan guards are deleted. The process manager gains a createAdapter seam so tests can point a real adapter's process spec at a raw script for spawn/stderr/exit mechanics. Protocol: message.delta, message.close, usage.turn and usage.exact leave the thread/delta schema; the assembler drops their handlers, its stream map and its usage accumulator; the G3 grammar snapshot records the deletion. The assembler speaks grammar [3, 3] and every bridge (codex, claude, acp, pi, echo, scripted echo) reports it; a bridge that predates grammarVersions reads as v2 and is refused at the handshake, and the conformance kit's handshake scenario now checks the range the same way. THREAD_DELTA_GRAMMAR_V2/V3 are exported from the protocol and the SDK. Tests: the fake adapter and its legacy-dialect script are deleted. The runtime unit suites, the daemon's thread.stop race suite, and the integration harness all run the scripted echo bridge through the real bootstrap, adapter and assembler; what a test needs to see the provider receive it reads from the bridge's request record or process log, and what a test needs the provider to do it scripts (new generic options: failMethods code/times, goalClearReportsCleared, toolCallThreadIdHint, approvalEnforcedBy, identifyProcess, failStopForThreadIds, emitIdentityOnSigterm; prompt directives hold_turn, fail_turn, prestart_fail; error answers to a pending tool call or interaction fail the turn). Assertions on literal turn/item ids became assertions on the assembler-minted ids the runtime reports. Tests whose only subject was a fake-adapter seam or a legacy-dialect behaviour are deleted (listed in the PR body). HOST_DAEMON_PROTOCOL_VERSION bumps to 148: the daemon now emits thread/extensionState/updated and its bridges speak a grammar a version-147 daemon would refuse. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Ten agent-runtime suites spawn the scripted echo bridge as a real child process (tsx compiles it on every spawn), and two tests hold a request open for over a second on purpose. On a slow CI runner two of them timed out at vitest's default 5s cap (runtime.multi-thread "handles multiple providers in a single runtime" at 5,026ms; runtime.lifecycle "allows thread/start to outlive the generic JSON-RPC timeout" at 5,008ms) with only ~30% headroom on a fast runner. Set testTimeout and hookTimeout to 15s at the config root; both vitest projects extend it, so the cap applies to the shared and the isolated worker alike. Verified with three back-to-back runs at --concurrency 4: 332/332 each, slowest test 2.1s. Co-Authored-By: Claude <noreply@anthropic.com>
6bef66f to
5567dee
Compare
…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>
…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>
…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>
…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>
… 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>
) ## What was wrong Codex labels each reconnect attempt with a structured `codexErrorInfo` (for example `{ responseStreamDisconnected: { httpStatusCode } }`, `willRetry: true`, failure text in `additionalDetails`), then reports the terminal failure for the same stream error with `codexErrorInfo: "other"` and the failure text moved to `message`. That downgrade is upstream: codex-rs `notify_stream_error` always labels retries `ResponseStreamDisconnected`, while `CodexErr::to_codex_protocol_error` maps `CodexErrorDetails::Stream` to `CodexErrorInfo::Other`. The bridge trusted the terminal value, so the final timeline row lost the `stream-disconnected` category and rendered as a generic **Provider error** (the detail text is longer than the 80-char title budget, so the disconnect cause was only visible after expanding the row). Issue: #1840. The independent report URL (https://get-bb.github.io/reports/issues/1840.html) returns 404; the issue body and #1563 carry the repro. ## What changed - `plugins/provider-codex/src/delta-translation.ts`: the translation state remembers the retry-time `codexErrorInfo` and failure text per codex `threadId\0turnId`. A terminal (`willRetry: false`) error whose `codexErrorInfo` is `other` and whose failure text equals the remembered retry text reuses the retry classification. The context is consumed by the terminal error, dropped on `turn/completed`, and never crosses turns. Unrelated terminal errors and every non-`other` terminal value keep the provider-reported classification. No provider prose is parsed. - `plugins/provider-codex/src/translator.ts`: `thread/closed` also clears the retry context for that codex thread (exported `clearCodexEventTranslationThreadState`). - No wire change between server and host daemon: the `provider/error` event shape is unchanged, only the value of `errorInfo` on this one path. No CLI or doc surface changes. Relation to #1563: that PR (same design, by @ymichael and @brsbl) targets `plugins/provider-codex/src/event-translation.ts`, which #1834 deleted when it moved Codex onto the narrow-grammar delta translator. It no longer merges (`git merge-tree` reports a content conflict in `delta-translation.ts`). This PR ports that design onto `delta-translation.ts`, with a flat `Map` keyed by thread+turn instead of nested maps. ## How you verified Tests added in `plugins/provider-codex/src/translator.test.ts` (`codex terminal retry-error classification`): - carries the retry classification into the degraded terminal error (and the context is consumed, so a repeat stays `other`) - does not relabel an unrelated terminal error after a reconnect - scopes the retry context to the turn and drops it on `turn/completed` - drops the retry context when the codex thread closes Fail-before: with `delta-translation.ts` and `translator.ts` restored from `origin/main`, `pnpm exec turbo run test --filter=bb-plugin-provider-codex --force -- --run src/translator.test.ts` fails the first test: ``` × carries the retry classification into the degraded terminal error AssertionError: expected [ { type: 'provider/error', …(7) } ] to deep equally contain ObjectContaining{…} expected "category": "stream-disconnected", "providerCode": "responseStreamDisconnected" received "category": "unknown", "providerCode": "other" ``` The three negative tests pass on `origin/main` as expected (they pin that the guard does not over-apply). Pass-after, from the committed tree (`git status --porcelain` empty): - `pnpm exec turbo run typecheck test --filter=bb-plugin-provider-codex` → `Tasks: 7 successful, 7 total` (16 test files, 176 tests passed) - `pnpm exec turbo run build` → `Tasks: 18 successful, 18 total` Manual replay of the incident's two events (reconnect with `responseStreamDisconnected`, then terminal `other` with the same `stream disconnected before completion: ...` text) through `createCodexEventTranslator` via `node --conditions=source --import tsx`: the terminal delta now carries `errorInfo: { category: "stream-disconnected", providerCode: "responseStreamDisconnected", httpStatusCode: null }`. ## Rebase Rebased onto `origin/main` after the grammar-v3 bridge stack landed (#2124, #2136, #2153, #2148, #2164). That stack rewrote `delta-translation.ts` (presentation on every item, `injectedToolsByName` on the translation state, delegation items) and `translator.ts` (`clearClosedThreadState` now returns the closes for open delegations), but it did not touch the `error` case, `toProviderErrorInfo`, or `turn/completed`, so the fix maps onto the new code unchanged: - The only textual conflict was in `CodexEventTranslationState` / `createCodexEventTranslationState`, where main added `injectedToolsByName` next to where this PR adds `retryErrorsByTurnKey`. Resolved by keeping both fields. - `clearCodexEventTranslationThreadState` is still called from `clearClosedThreadState` in `translator.ts`, before it returns the delegation closes that main added. - The tests merged cleanly into `translator.test.ts`; they run through the grammar-v3 `createDeltaAssembler` harness on main, and the `provider/error` event shape they assert is unchanged. Re-verified on the new base (`798b720ef`, one commit on top of `origin/main`): - Fail-before: with `delta-translation.ts` and `translator.ts` restored from `origin/main`, `pnpm exec turbo run test --filter=bb-plugin-provider-codex --force -- --run src/translator.test.ts -t "codex terminal retry-error classification"` → `1 failed | 3 passed`; `carries the retry classification into the degraded terminal error` fails with expected `"category": "stream-disconnected", "providerCode": "responseStreamDisconnected"`, received `"category": "unknown", "providerCode": "other"`. The bug is still present on current main. - Pass-after, from the committed tree (`git status --porcelain` empty): `pnpm exec turbo run typecheck test --filter=bb-plugin-provider-codex --force` → `Tasks: 7 successful, 7 total`, `Test Files 18 passed (18)`, `Tests 197 passed (197)`. `pnpm exec turbo run build` → `Tasks: 18 successful, 18 total`. Fixes #1840 > AGENT GENERATED: by Claude Opus 5 ## Independent verification Verified on a fresh checkout of `bb/fix-1840-codex-stream-disconnect` (282f32e, one commit on top of `origin/main`; `git merge-base --is-ancestor origin/main HEAD` true, GitHub reports MERGEABLE). Root cause checked against current upstream sources (not from the PR description): `codex-rs/core/src/session/mod.rs` `notify_stream_error` hard-codes `CodexErrorInfo::ResponseStreamDisconnected` for every retry notification, `codex-rs/core/src/responses_retry.rs` returns the raw `CodexErr` once retries are exhausted, and `codex-rs/protocol/src/error.rs` `to_codex_protocol_error` has no arm for `CodexErrorDetails::Stream` so it hits `_ => CodexErrorInfo::Other`. The app-server maps `EventMsg::StreamError` to `error` with `willRetry: true` plus `additionalDetails`, and `EventMsg::Error` to `willRetry: false` with `additional_details: None`. The PR's correlation (retry `additionalDetails` vs terminal `message`, same thread+turn, `other` only) matches that wire shape exactly. Commands: - `pnpm install --frozen-lockfile --prefer-offline` and `pnpm exec turbo run build` (18/18). - Fail-before: `git checkout origin/main -- plugins/provider-codex/src/delta-translation.ts plugins/provider-codex/src/translator.ts`, then `pnpm exec turbo run test --filter=bb-plugin-provider-codex --force -- --run src/translator.test.ts -t "codex terminal retry-error classification"`: 1 failed, 3 passed. Failing assertion: `carries the retry classification into the degraded terminal error` -> `AssertionError: expected [ { type: 'provider/error', ...(7) } ] to deep equally contain ObjectContaining{...}`, expected `"category": "stream-disconnected", "providerCode": "responseStreamDisconnected", "httpStatusCode": 502`, received `"category": "unknown", "providerCode": "other", "httpStatusCode": null`. - Pass-after: restored the PR sources (`git status --porcelain` empty), `pnpm exec turbo run typecheck test --filter=bb-plugin-provider-codex --force` -> `Tasks: 7 successful, 7 total`, `Test Files 16 passed (16)`, `Tests 176 passed (176)`. - Only `packages/thread-view/src/error-display.ts` consumes the `stream-disconnected` category (title text); no runtime recovery keys on it, so the blast radius is the timeline row title. Repro on the fixed branch: a real Codex stream outage cannot be triggered deterministically here, so I replayed upstream-shaped events through `createCodexEventTranslator` directly (`node --conditions=source --import tsx`): four `Reconnecting... n/5` retries labelled `responseStreamDisconnected` with the failure text in `additionalDetails`, then a terminal `other` with that text as `message` and `additionalDetails: null`. Fixed branch: `{"category":"stream-disconnected","providerCode":"responseStreamDisconnected","httpStatusCode":null}`. Same script with `origin/main` sources: `{"category":"unknown","providerCode":"other"}`. Extra negative replays on the fixed branch all stayed correct: unrelated terminal text after a retry stays `unknown`; a structured terminal value (`responseTooManyFailedAttempts`, 503) is never overridden by the remembered retry; a thread-scoped retry (no `turnId`) does not relabel a turn-scoped terminal; a retry on a different codex thread does not leak across threads. CI: all checks pass (Checks, Package Smoke ubuntu+macos, Tests app-1/2/3, integration, server, packages, version check). Residual risks (minor, not blocking): the correlation needs the terminal `message` to equal the last notified retry's `additionalDetails`; the terminal `CodexErr` is the attempt after the last notified retry, so if the inner reqwest text differs between attempts the row falls back to today's generic label (never to a wrong one). Retry context for a turn whose child dies without `thread/closed` or `turn/completed` lives in the per-session translator until the session is released (a few bytes). The upstream mapping gap in codex-rs remains. > AGENT GENERATED: by Claude Opus 5 ## Independent verification (post-rebase) Re-verified after the rebase onto the grammar-v3 bridge. Checked out `798b720ef` (one commit on top of `cf00cfe06`); `origin/main` had since gained #2120 (provider-literal ratchet), which merges cleanly and excludes `plugins/provider-*`, so it cannot affect this PR (`node scripts/check-provider-literal-ratchet.mjs --base origin/main` -> `ratchet OK: 148 references across 40 core files`). Fix still targets the right code path in the rewritten translator: the v3 stack left the `error` case, `toProviderErrorInfo`, and `turn/completed` unchanged; `clearCodexEventTranslationThreadState` runs inside `clearClosedThreadState` before the delegation closes it now returns; `translateEvent` still routes `error` events through `translateCodexEventToDeltas(event, eventTranslationState)` with the single per-session state. Nothing downstream reclassifies: `@bb/agent-runtime` `shouldRestartCodexThreadAfterEvent` keys only on `rate-limit`/`unauthorized` categories (a retry-time label is always `stream-disconnected`, so restart policy is unchanged) and `packages/thread-view/src/error-display.ts` is the only consumer of the category. Commands (all from the committed tree): - Fail-before on current main source: `git checkout origin/main -- plugins/provider-codex/src/delta-translation.ts plugins/provider-codex/src/translator.ts`, then `pnpm exec vitest run src/translator.test.ts -t "codex terminal retry-error classification"` in `plugins/provider-codex` -> `1 failed | 3 passed`; `carries the retry classification into the degraded terminal error` fails with expected `"category": "stream-disconnected", "providerCode": "responseStreamDisconnected", "httpStatusCode": 502`, received `"category": "unknown", "providerCode": "other", "httpStatusCode": null` (`src/translator.test.ts:1272`). - Pass-after (sources restored, `git status --porcelain` empty): same vitest command -> `4 passed`. `pnpm exec turbo run typecheck test --filter=bb-plugin-provider-codex --force` -> `Tasks: 7 successful, 7 total`, `Test Files 18 passed (18)`, `Tests 197 passed (197)`. - Dependents: `pnpm exec turbo run typecheck test --filter=@bb/provider-parity --filter=@bb/agent-runtime --filter=@bb/provider-bridge-protocol --force` -> `Tasks: 10 successful, 10 total` (parity 43 passed, agent-runtime 439 passed, bridge-protocol 217 passed). The parity suite replays every committed recording, including `recordings/codex/auth-failure`, through the current bridge with zero diffs. - `pnpm exec prettier --check` and `pnpm exec eslint` on the three touched files: clean. Repro on the fixed branch: replayed the issue's event sequence (four `Reconnecting... n/5` retries labelled `responseStreamDisconnected` with the failure text in `additionalDetails`, then terminal `other` with that text as `message`) through `createCodexEventTranslator` via `node --conditions=source --import tsx`: terminal delta is `{"category":"stream-disconnected","providerCode":"responseStreamDisconnected","httpStatusCode":null}`. A terminal-only event with no preceding retry stays `unknown`/`other` (by design). Cross-checked against upstream `codex-rs/protocol/src/error.rs` (`Stream(..)` still falls to `_ => CodexErrorInfo::Other`; terminal `message` is `self.to_string()`, the same text `notify_stream_error` puts in `additional_details`). CI: all 11 check-runs on `798b720ef` succeed (Checks, Package Smoke ubuntu+macos, Tests app-1/2/3, integration, server, packages, version check x2); 2 skipped (node-compat smoke, iOS flows). Residual risk (minor, not blocking): the real recorded `auth-failure` cell shows Codex's failure text can carry per-request `cf-ray`/`request id` values that differ between the last retry and the terminal attempt; there the exact-text guard does not fire and the terminal row stays the generic label exactly as on main (for that 401 case a `stream-disconnected` label would arguably be wrong anyway, and the runtime's 401 restart text pattern still matches). Retry context for errors without a `turnId` is only dropped on `thread/closed`. > AGENT GENERATED: by Claude Opus 5 Co-authored-by: Claude <noreply@anthropic.com>
## What was wrong Issue #1529 (report: https://get-bb.github.io/reports/issues/1529.html). The wedge itself lives in Cursor's CLI: its Shell tool re-spawns every command with the cwd persisted from the last run, Node's `spawn` fails with ENOENT once that directory is deleted (`git worktree remove`), and Cursor's ACP adapter reports the call as `status: "completed"` with no `content` and no `rawOutput`. bb cannot fix that, but bb made it invisible. `plugins/provider-acp/src/delta-translation.ts` derived the exit code from the ACP status alone (`completed` -> 0, `failed` -> 1). ACP has no exit-code field, and Cursor also reports a command that really exited non-zero as `status: "completed"` with the real code in `rawOutput.exitCode`. So the bb timeline and `bb thread log` showed "exit code 0" both for commands that never ran and for commands that exited 1. ## What changed - `plugins/provider-acp/src/wire.ts`: `acpToolCallRawOutputExitCodeSchema` parses `{ exitCode: integer }` out of the agent-defined `rawOutput` payload at the boundary. - `packages/provider-bridge-protocol/recordings/acp-cursor/*/bridge→runtime.current.ndjson`: re-recorded acp-cursor lanes (six cells); only `exitCode` on item closes changed. Details under Rebase below. - `plugins/provider-acp/src/delta-translation.ts`: `toolCallClose` now gets its exit code from `extractAcpExitCode`. A reported integer `rawOutput.exitCode` wins. Without one, a `failed` call still maps to 1 (non-zero is all we know) and a `completed` call carries no `exitCode` instead of a fabricated 0. `exitCode` is already optional on the `commandExecution` item, the delta close, the thread-view exec lifecycle, the CLI formatter, and the app's `TerminalOutputBlock`, so consumers render "unknown" by omitting the exit-code line. Behavior change for every ACP provider: a completed command without a reported exit code no longer shows `exit code 0`; a completed command with `rawOutput.exitCode: 1` now renders as an error with `exit 1`. Translation only, nothing crosses the server/daemon wire, so no `HOST_DAEMON_PROTOCOL_VERSION` bump. I did not take the report's optional step of mapping a result-less completed call to `failed`: that guesses, and omitting the exit code is the honest signal. The wedge remains upstream (Cursor CLI, `cursor-agent` 2026.08.11). With this change the agent's own "returned no exit status" text is no longer contradicted by a bb row claiming success. ## How you verified New tests in `plugins/provider-acp/src/delta-translation.test.ts` (`command exit codes`), using the exact `tool_call`/`tool_call_update` shapes recorded from `cursor-agent acp`. Against the origin/main sources they fail: ``` × uses rawOutput.exitCode when the agent reports a non-zero exit as completed AssertionError: expected +0 to be 1 × omits the exit code when a completed call carries no result at all AssertionError: expected +0 to be undefined × prefers a reported exit code over the failed-status fallback AssertionError: expected 1 to be 127 × ignores non-integer exit codes in rawOutput AssertionError: expected +0 to be undefined ``` With the fix: `pnpm exec turbo run typecheck test --filter=bb-plugin-provider-acp` passes (14 files, 186 tests). The existing `translates execute tool calls into command executions` expectation dropped its `exitCode: 0` (no exit code was reported in that fixture). Manual check on my dev instance with a real `acp-cursor` thread and the report's prompt (scratch repo + worktree, `git worktree remove --force`, then `echo hi; git status`, then `pwd` with a working-directory override, then `false`). `bb thread log --format verbose` after the fix: ``` ── Ran echo hi; git status $ echo hi; git status <- no fabricated "exit code 0" ── Ran pwd {"exitCode":0,"stdout":".../repo/base\n","stderr":""} ── Ran false (error) {"exitCode":1,"stdout":"","stderr":""} exit 1 <- was "exit code 0" before ``` The persisted items: the two wedged `echo hi; git status` calls are `status: "completed"` with no `exitCode`; `false` is `exitCode: 1`. ## Rebase Rebased onto current main (`cf00cfe06`) after #2136 (WS1a generic assembler) and #2179 (WS1b-acp: ACP bridge to grammar v3 with presentation) rewrote `plugins/provider-acp/src/delta-translation.ts`. What moved: - `extractAcpToolCallOutputText`, `buildAcpFileChanges`, and `classifyAcpToolCall` now live in `plugins/provider-acp/src/tool-classification.ts` and return `{ item, presentation }`; the PR's conflict hunks that carried copies of those helpers were dropped, not re-added. - `toolCallClose` still derived the exit code from the ACP status on main (`terminal ? { exitCode: status === "failed" ? 1 : 0 } : {}`), so the bug was still present. The fix maps one-to-one: `extractAcpExitCode(args.event, args.status)` replaces the `terminal` flag, and the close spreads `exitCode` only when one is known. The new `classified.item` / `classified.presentation` fields and the injected-tool binding cleanup from #2179 are kept as-is. - `wire.ts` (`acpToolCallRawOutputExitCodeSchema`) applied without conflict. - Test fixture `translates execute tool calls into command executions` keeps main's new `presentation` block and drops `exitCode: 0`, as before. Re-verified on the new base. With `git checkout origin/main -- delta-translation.ts wire.ts`, `vitest run src/delta-translation.test.ts`: 5 failed / 32 passed (`expected +0 to be 1`, `expected +0 to be undefined`, `expected 1 to be 127`, `expected +0 to be undefined`, plus the fixture diff). With the fix restored, from the committed tree: `pnpm exec turbo run typecheck test --filter=bb-plugin-provider-acp --force` → `Tasks: 7 successful, 7 total`; `Test Files 15 passed (15)`, `Tests 201 passed (201)` (includes the recorded ACP conformance cells). Fixes #1529 > AGENT GENERATED: by Claude Opus 5 ### Rebase onto `75d6fc4d4` and acp-cursor re-record Rebased onto current main (`75d6fc4d4`, five commits ahead of the previous base `cf00cfe06`: #2202, #2201, #2147, #2150, #2120). None touch `plugins/provider-acp/src/{delta-translation,wire}.ts`; the rebase applied clean and the fix is unchanged. The verifier found that the fix changes what the ACP bridge emits for committed acp-cursor recordings, so `pnpm exec turbo run test --filter=@bb/provider-parity --force` failed on `acp-cursor/{approval-deny,steer,web-search}` (3 failed / 40 passed on the rebased tree before this change; CI had only been green through a turbo cache hit). Ran `pnpm --filter @bb/provider-parity rerecord --provider acp-cursor` (planning with this checkout's assembler; no `--plan-with` needed) and committed the resulting `bridge→runtime.current.ndjson` lanes. Checked every changed line at the delta level (parsed old vs new, removed `exitCode`, deep-equal): the ONLY change is `exitCode` on `item.close` deltas. Line counts are unchanged, row-count pins are unchanged, `parity-allowlist.json` is untouched. - `approval-deny`: `touch ~/bb-recording-outside.txt` close, `exitCode: 0` -> absent (Cursor reported no `rawOutput`). - `steer`: the fourth `sleep 2` close, `exitCode: 0` -> absent (the steer interrupted it before a `completed` update; the other four keep their reported `0`). - `web-search`: the `node -e ...` close, `exitCode: 0` -> `1` (Cursor reported `rawOutput.exitCode: 1` under `status: "completed"`; this is the #1529 shape). - `subagent`, `turn-tools`, `user-question`, `web-search`: `tool`/`fileChange` closes drop the `exitCode: 0` the old code attached to every terminal close. The assembler only reads `exitCode` on commands, which is why those cells passed before; the re-recorded lanes now match the wire. - `fork` re-recorded identically except for the recording machine's checkout path inside the "does not advertise session/fork support" error string; left as committed. Re-verified from the committed tree (`git status --porcelain` empty). Fail-before: with `git checkout origin/main -- delta-translation.ts wire.ts`, `vitest run src/delta-translation.test.ts` -> 5 failed / 32 passed (`expected +0 to be 1`, `expected +0 to be undefined`, `expected 1 to be 127`, `expected +0 to be undefined`, plus the fixture diff). Pass-after: 37 passed. `pnpm exec turbo run typecheck test --filter=bb-plugin-provider-acp --filter=@bb/provider-bridge-protocol --filter=@bb/provider-parity --force` -> `Tasks: 11 successful, 11 total` (`Cached: 0 cached`); `bb-plugin-provider-acp` 15 files / 201 tests, `@bb/provider-bridge-protocol` 15 files / 217 tests, `@bb/provider-parity` 1 file / 43 tests (all 43 cells reproduce their recordings). ## Independent verification Checked out `bb/fix-1529-shell-session-deleted-cwd` (e042d5a, one commit on top of 2ff8598) in a separate worktree; `git merge-tree --write-tree origin/main HEAD` merges clean against current main (703213a). PR diff is limited to `plugins/provider-acp/src/{delta-translation.ts,wire.ts,delta-translation.test.ts}`; no wire change between server and daemon, so no `HOST_DAEMON_PROTOCOL_VERSION` bump needed. Consumers of `exitCode` (`delta-assembler` close fields, `thread-view/exec-lifecycle.ts`, `format-timeline-text.ts`, `TerminalOutputBlock`) all treat it as optional/nullable; a missing code falls back to the item status, a non-zero code maps to `error`. Fail-before / pass-after (`pnpm exec vitest run src/delta-translation.test.ts` in `plugins/provider-acp`): - With `git checkout origin/main -- delta-translation.ts wire.ts`: 5 failed / 20 passed. `expected +0 to be 1`, `expected +0 to be undefined`, `expected 1 to be 127`, `expected +0 to be undefined`, plus `translates execute tool calls into command executions` (fixture no longer expects the fabricated `exitCode: 0`). - With the PR sources restored: 25 passed. `pnpm exec turbo run typecheck test --filter=bb-plugin-provider-acp --force`: 7 tasks successful, 14 test files / 186 tests passed. `gh pr checks 2131`: all checks pass (Checks, Package Smoke x2, Tests app-1/2/3, integration, packages, server, version check). Repro on the fixed branch (own dev instance, scratch repo + worktree, real `acp-cursor` thread thr_eenfcb5cv5 with the report's prompt plus a final `false`): `bb thread log --format verbose` shows `Ran echo hi; git status` with no output and no exit-code line, and `Ran false (error) ... exit 1`. Persisted items: seq 42 `{"command":"echo hi; git status","status":"completed"}` (no `exitCode`), seq 54 `{"command":"false","status":"completed","exitCode":1}`. On main the same log printed `exit code 0` for both. The upstream wedge itself still reproduces (Cursor returns "The shell command returned no exit status" for step 3 and heals after the working-directory override), which is outside bb's control. Residual risks: completed ACP commands from agents that do not report `rawOutput.exitCode` (or use a different key) no longer show `exit code 0`; a Cursor call that never ran is still rendered as a completed row with no output rather than as failed. Merging closes #1529 although the wedge needs an upstream Cursor CLI fix; the body above says so. > AGENT GENERATED: by Claude Opus 5 ## Independent verification (post-rebase) Checked out `bb/fix-1529-shell-session-deleted-cwd` at 06c27aa (one commit on top of 75d6fc4) in a fresh worktree. Since the rebase main gained only 85eec4d (#2210, iOS TestFlight CI; no overlap) and GitHub reports the PR `MERGEABLE`. The fix still targets the live path after today's grammar-v3 bridge stack: `createAcpDeltaTranslator` is wired once in `plugins/provider-acp/src/bridge/bridge.ts`, and `toolCallClose` is the only place the ACP bridge emits a command `exitCode`; no legacy `event-translation.ts` remains. The delta/assembler contract is unchanged (`exitCode` already optional in `thread-delta.ts` and applied only to `commandExecution` in `delta-assembler.ts`), so no `HOST_DAEMON_PROTOCOL_VERSION` bump is needed. `thread-view` projects a missing code to `null` (no exit line) and a non-zero one to an `error` row. Recordings: parsed every changed line of the six re-recorded `acp-cursor/*/bridge→runtime.current.ndjson` lanes against 75d6fc4 with `exitCode` stripped; all 9 changed deltas are deep-equal otherwise, line counts unchanged, other acp-cursor cells byte-identical. The `web-search` `node -e` close moves 0 -> 1 because Cursor reported `rawOutput.exitCode: 1` under `status: "completed"` (the #1529 shape in a real recording). Fail-before / pass-after (`pnpm exec vitest run src/delta-translation.test.ts` in `plugins/provider-acp`): with `git checkout origin/main -- plugins/provider-acp/src/delta-translation.ts plugins/provider-acp/src/wire.ts` -> 5 failed / 32 passed: `expected +0 to be 1`, `expected +0 to be undefined`, `expected 1 to be 127`, `expected +0 to be undefined`, plus the `translates execute tool calls into command executions` fixture. Sources restored -> 37 passed (37). From the committed tree (`git status --porcelain` empty), after `pnpm install --frozen-lockfile --prefer-offline` and `pnpm exec turbo run build` (18/18): `pnpm exec turbo run typecheck test --filter=bb-plugin-provider-acp --filter=@bb/provider-bridge-protocol --filter=@bb/provider-parity --force` -> `Tasks: 11 successful, 11 total`, `Cached: 0 cached`; bb-plugin-provider-acp 15 files / 201 tests, @bb/provider-bridge-protocol 15 files / 217 tests, @bb/provider-parity 1 file / 43 tests. `gh pr checks 2131`: all required checks pass on 06c27aa. Repro on the fixed branch (own dev instance, scratch repo + `git worktree add`, real `acp-cursor` thread thr_5mn7xbb4j2 with the report's verbatim prompt): the upstream wedge still reproduces (Cursor returns "The shell command returned no exit status" for steps 3 and 4 and heals after the working-directory override), and bb now persists those two items as `{"command":"echo hi; git status","status":"completed"}` / `{"command":"echo alive","status":"completed"}` with no `exitCode` and no output, where main wrote `exitCode: 0`. A second thread (thr_sxg224nzrj) in which Cursor reported `rawOutput.exitCode: 127` and `126` under `status: "completed"` rendered as `exit 127` / `exit 126` `(error)` rows in `bb thread log`. Residual risks: unchanged from the previous section. The wedge itself is a Cursor CLI bug; merging closes #1529 on the bb side only (misreported exit codes), which the body states. > AGENT GENERATED: by Claude Opus 5 Co-authored-by: Claude <noreply@anthropic.com>
…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>
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 legacyProviderAdapterpath 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 (
UnsupportedDeltaShapeErrorin every v3 shape and inextension.state), kept two streaming and two usage dialects, validated no extension payload, published no testing kit, and the runtime still carried a legacyProviderAdapterinterface 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)
92ae9cd) —fileRead,search,planStepsopen pending and settle from the terminal shape likecommand; a foregrounddelegationsettles throughitem/completed, abackground: truedelegation is thread-attached like a background task (item/delegation/progress|completed, no turn needed, survives turn settlement andsession.ended).ASSEMBLER_GRAMMAR_VERSIONS→[2, 3].fc88906); nothing to do here.f174c5d) —extensionitems and the newthread/extensionState/updatedevent 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 asprovider/unhandledin the same batch slot — G11-visible, never dropped, never stored unvalidated.extensionKindSchemaparses to theExtensionKindtype.cce9415) — every text stream is an item keyed like any other:item.textDelta/item.textClose, anonymous streams keyed bykey.channel(+parentRef).usage { total, last, modelContextWindow }is forwarded verbatim; bridges that report per turn (claude, pi) accumulate with the bridge kit'saddTokenUsageand reset atsession.reset; codex sendscontextWindow(now withproviderTurnId) beside it. All four bridges + the echo example migrated. Calibration goldens unchanged for codex, claude, acp, pi.provider/recovery(e5f5a3b) — decoded by the adapter, forwarded to the runtime's newonProviderRecoveryhook (the daemon logs it; WS4 acts per kind). Grammar negotiation shipped upstream (0816b4c).03815e3) —@get-bb/plugin-sdk/provider-bridge/testing: conformance kit, the real assembler, delta→event collector, JSON-RPC harness, calibration normalizer. Framework-agnostic (captureBridgeJsonRpcOutputpatchesprocess.stdout.write, novi).experimental_value names +docs/api_to_audit.mdentry; G10 doc-sync test asserts the entry. The assembler moved into@bb/provider-bridge-protocol(assemblersubpath) — 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 viaproviderOptions.scripted/SCRIPTED_ECHO_OPTIONS;SCRIPTED_ECHO_RECORD_PATHrecords every request,SCRIPTED_ECHO_PROCESS_LOG_PATHevery process step). Passes the conformance suite. The integration harness has noadapterFactoryseam: the fake providers are declarations backed by the built scripted artifact, run by the daemon through the real adapter.a9a3950) —ProviderAdapter→ concreteBridgeProtocolAdapter;adapterFactory/createAgentRuntimeWithAdapters; the fake adapter + script;message.delta/close,usage.turn/exactfrom schema + assembler; assembler[3, 3]and every bridge reports it (a bridge that predatesgrammarVersionsreads 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 acreateAdapterseam for raw-script spawn/stderr/exit tests.HOST_DAEMON_PROTOCOL_VERSION→ 148.Tests deleted (subject no longer exists)
buildCommandPlanseam could plan a noop; handshake gating is pinned inbridge-protocol-adapter.test.ts).translateAcceptedCommandis a no-op for bridges), "preserves merged shell env when reconfiguring a thread" (the session-rebuild path is unreachable: bridges classify every settings change aslive; 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 thetranslateEventoverride; the grammar gate is exercised by the new replayed-turn bridge test).userMessage).thread/eventdialect routing; everythread/deltanames its bb thread id).Everything else is ported faithfully; literal-id assertions became assertions on the assembler-minted ids (fix(agent-runtime,thread-view): stop timeline 500s from session-scoped ACP fs-write ids #1224),
AdapterCommandrecordings became request-record assertions on the same wire facts.Regression oracle status
bb/provider-recordingsdoes not exist on origin; not in the base. Coordinator requires it before merge.Intended byte-level difference (allowlist)
session.endednow 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_VERSION147 → 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_VERSIONstays 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/dbandprovider-codex provider-claude-code provider-acp provider-pi echo-provider scripted-echo-provider @bb/integration-tests @bb/app @bb/mobile @bb/cli.Tests:
internal-skill-trees(umask 0664 vs 0644, passes in CI) andplugin-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.