Skip to content

[stacked on #2121 ← #2148 ← #2153 ← #2136 ← #2124] WS1b-codex: Codex bridge to grammar v3 with presentation - #2164

Merged
SawyerHood merged 6 commits into
bb/provider-baselinesfrom
bb/ws1b-codex-codex-bridge-to-v3-stack-on-2136-thr_xnid5ftd87
Aug 21, 2026
Merged

[stacked on #2121 ← #2148 ← #2153 ← #2136 ← #2124] WS1b-codex: Codex bridge to grammar v3 with presentation#2164
SawyerHood merged 6 commits into
bb/provider-baselinesfrom
bb/ws1b-codex-codex-bridge-to-v3-stack-on-2136-thr_xnid5ftd87

Conversation

@SawyerHood

@SawyerHood SawyerHood commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

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 #2121this 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/closeplugins/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 [stacked on #2153 ← #2136 ← #2124] WS2a: bb.providers.register + single ProviderInfo #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 kindsprovider-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 paththread.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 f6fb434ab> --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/clearedprovider-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 ([stacked on #2136 ← #2124] Add bridge record mode, real provider recordings, and the parity harness #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 [stacked on #2136 ← #2124] Add bridge record mode, real provider recordings, and the parity harness #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

@SawyerHood
SawyerHood force-pushed the bb/ws1b-codex-codex-bridge-to-v3-stack-on-2136-thr_xnid5ftd87 branch 2 times, most recently from e6fc0dd to db4f1f7 Compare August 21, 2026 07:00
@SawyerHood
SawyerHood marked this pull request as ready for review August 21, 2026 08:39
@SawyerHood
SawyerHood force-pushed the bb/ws1b-codex-codex-bridge-to-v3-stack-on-2136-thr_xnid5ftd87 branch from 5a7a0e1 to b6d7c6e Compare August 21, 2026 09:18
@SawyerHood

Copy link
Copy Markdown
Collaborator Author

Coordinator review — APPROVE (SlopCop disabled; this review is the gate). Lands after #2148 (rebase onto it first; protocol.ts is a no-op since 149 was copied verbatim).

The oracle result is the best in the stack so far: codex parity 16/0/1 with zero unlisted and zero stale allowlist entries; claude-code 13/13 and acp-cursor 10/10 untouched; A4 zero row diffs across all 307 production threads — the 721-row goal thread renders identically through the read-time conversion. G11 flat. That is precisely what the regression program was built to prove.

Review items, all resolved on head b6d7c6ecc:

Design notes accepted: provider-codex/* namespace (plugin id is the namespace — correct); the minimal delegation projection (right call; WS1b-claude/acp rebase on this); planSteps unprojected (status quo → WS3); presentation optional on the wire (A1).

Routed elsewhere, not this PR's debt: A5 matrix re-port → #2121 (dispatched); live QA cells for codex → scheduled before the stack lands.

Sawyer merges; I do not.

AGENT GENERATED: by Claude Opus 4.8

@SawyerHood
SawyerHood force-pushed the bb/ws1b-codex-codex-bridge-to-v3-stack-on-2136-thr_xnid5ftd87 branch from b6d7c6e to 73cd7b8 Compare August 21, 2026 09:45
@SawyerHood
SawyerHood force-pushed the bb/ws1b-codex-codex-bridge-to-v3-stack-on-2136-thr_xnid5ftd87 branch from 73cd7b8 to 96ffafb Compare August 21, 2026 09:54
@SawyerHood SawyerHood changed the title [stacked on #2136] WS1b-codex: Codex bridge to grammar v3 with presentation [stacked on #2148 ← #2136 ← #2124] WS1b-codex: Codex bridge to grammar v3 with presentation Aug 21, 2026
@SawyerHood
SawyerHood changed the base branch from bb/ws1a-assembler-testing-kit-stack-on-2124-thr_jkbj56vr97 to bb/ws2a-registry-providerinfo-stack-on-2124-thr_hbi4kggyzb August 21, 2026 09:54
SawyerHood added a commit that referenced this pull request Aug 21, 2026
Harness fixes found while stacking the codex bridge migration (#2164) on
WS1a's v3-only assembler. None of them changes a recording; all are safe on
this branch (self-suite 43/43, codex/claude/acp recorded conformance green).

- A recording is never rewritten. `pnpm rerecord [--plan-with <checkout>]`
  writes the current bridge's side of the wire to
  bridge→runtime.current.ndjson beside the recorded lane (request ids kept
  by method and order so the recorded runtime responses still match; lines
  placed after the runtime entry sent last before them). The self-suite and
  recorded conformance pin and compare against the current lane when one
  exists; `pnpm parity` paces a pre-migration leg from the recorded lane
  and the current leg from the current lane (`planFromCurrentLane`), so
  each assembler plans from a lane it parses whole. `--plan-with` names a
  checkout whose assembler parses the recorded lane for the re-recording
  itself.
- `pnpm parity --dump-dir <dir>` writes both legs' normalized event and row
  lists per cell, for allowlist entries that must name a list index (the
  CLI renders values truncated to 160 chars).
- The replay drains the pre-request stream (300 ms quiet once the gates are
  met) before sending each runtime request, and the replay child leaves a
  50 ms gap after a response line: a steer's ack follows an `await` on the
  bridge's own request, and on a loaded runner the next notification
  otherwise lands in the same read and the ack moves one position (two CI
  flakes on #2164).
- The self-suite holds allowlist entries to their form (PR, reason, path)
  instead of requiring an empty list, which a migration PR cannot satisfy;
  a non-replayable provider whose recorded lane predates the grammar is
  reported, not failed, until re-recorded live.

Co-Authored-By: Claude <noreply@anthropic.com>
@SawyerHood
SawyerHood force-pushed the bb/ws1b-codex-codex-bridge-to-v3-stack-on-2136-thr_xnid5ftd87 branch from 96ffafb to ecba401 Compare August 21, 2026 10:28
@SawyerHood
SawyerHood force-pushed the bb/ws1b-codex-codex-bridge-to-v3-stack-on-2136-thr_xnid5ftd87 branch from ecba401 to 58d5a58 Compare August 21, 2026 10:39
@SawyerHood SawyerHood changed the title [stacked on #2148 ← #2136 ← #2124] WS1b-codex: Codex bridge to grammar v3 with presentation [stacked on #2121 ← #2148 ← #2153 ← #2136 ← #2124] WS1b-codex: Codex bridge to grammar v3 with presentation Aug 21, 2026
@SawyerHood
SawyerHood changed the base branch from bb/ws2a-registry-providerinfo-stack-on-2124-thr_hbi4kggyzb to bb/provider-baselines August 21, 2026 10:39
SawyerHood added a commit that referenced this pull request Aug 21, 2026
Harness fixes found while stacking the codex bridge migration (#2164) on
WS1a's v3-only assembler. None of them changes a recording; all are safe on
this branch (self-suite 43/43, codex/claude/acp recorded conformance green).

- A recording is never rewritten. `pnpm rerecord [--plan-with <checkout>]`
  writes the current bridge's side of the wire to
  bridge→runtime.current.ndjson beside the recorded lane (request ids kept
  by method and order so the recorded runtime responses still match; lines
  placed after the runtime entry sent last before them). The self-suite and
  recorded conformance pin and compare against the current lane when one
  exists; `pnpm parity` paces a pre-migration leg from the recorded lane
  and the current leg from the current lane (`planFromCurrentLane`), so
  each assembler plans from a lane it parses whole. `--plan-with` names a
  checkout whose assembler parses the recorded lane for the re-recording
  itself.
- `pnpm parity --dump-dir <dir>` writes both legs' normalized event and row
  lists per cell, for allowlist entries that must name a list index (the
  CLI renders values truncated to 160 chars).
- The replay drains the pre-request stream (300 ms quiet once the gates are
  met) before sending each runtime request, and the replay child leaves a
  50 ms gap after a response line: a steer's ack follows an `await` on the
  bridge's own request, and on a loaded runner the next notification
  otherwise lands in the same read and the ack moves one position (two CI
  flakes on #2164).
- The self-suite holds allowlist entries to their form (PR, reason, path)
  instead of requiring an empty list, which a migration PR cannot satisfy;
  a non-replayable provider whose recorded lane predates the grammar is
  reported, not failed, until re-recorded live.

Co-Authored-By: Claude <noreply@anthropic.com>
SawyerHood and others added 6 commits August 21, 2026 10:49
Grammar v3 moves tool-name knowledge out of core thread-view and into the
bridge: the codex bridge now states how each of its items reads (label while
pending, label once settled, a host glyph, an optional headline, and whether
clients may collapse the row) on every lifecycle delta, and the assembler
persists that snapshot with the item.

plugins/provider-codex/src/presentation.ts is the one place codex natives
map to row presentation: shell commands (the `-lc` wrapper stripped from the
headline), file edits (file names, pluralized), the bundled node_repl server
("Ran JavaScript" with the call's human title), other MCP servers by tool
name, dynamic tools (AskUserQuestion collapsed behind its interaction row),
collab verbs, web search/fetch, image views, reasoning, messages, plans,
compactions, and the synthesized sub-agent spawn.

The per-event suite pins the persisted presentation on the canonical items;
a new invariant suite drives one item per codex native through the real
translator and asserts every item.open/item.close carries one.

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

Codex's delegated work becomes the grammar v3 `delegation` kind instead of a
`tool` item named spawnAgent:

- The synthesized sub-agent row (from `subAgentActivity`) is a foreground
  delegation: `childRef` is the agent thread id, `label` its agentPath. A
  follow-up to a settled agent re-opens the same item (the assembler keeps
  the minted id for a known provider id), and the row closes only when the
  agent owes nothing more — the exact predicate the bridge's open-work
  report used. When the app-server child dies, every delegation still open
  settles as failed on the wire.
- A collab call that names its receiver (spawnAgent/resumeAgent/sendInput,
  or a wait/closeAgent scoped to one agent) is a delegation to that child,
  with the prompt as its label and codex's agentsStates as the terminal
  summary. A bare `wait` (every agent) has no child and stays a generic tool
  call with its collab presentation.
- `turn/plan/updated` (codex update_plan, 295 production threads the UI
  discards today) becomes a settled `planSteps` snapshot per update,
  channel-keyed so each snapshot is its own item and the latest supersedes.

Open delegations are open work: RuntimeBackgroundWorkState now counts a
pending delegation (item/started, the thread-scoped item/delegation/* for
background ones) the way it counts background tasks, from the same event
stream — the replacement for the bridge's out-of-band `thread/openWork`,
which the last commit of this stack deletes.

Thread-view projects a `delegation` item to the existing delegation row with
the child turn's content nested under it. Without this the row would vanish
before the projection workstream lands, and with it every child message
whose only anchor is the parent call (the projection suppresses orphans
that carry a parentToolCallId). The v3 presentation-driven rows for every
kind remain that workstream's.

Co-Authored-By: Claude <noreply@anthropic.com>
…on their calls

Q31: a call to a bb tool reads the way the tool's definition says, not the
way a core table of bb tool names says. Three pieces:

- `bb.agents.registerTool({ experimental_presentation })` lets a plugin
  declare the row presentation of its tool (label pair, glyph, suppress,
  tint; docs/api_to_audit.md entry). The server resolves one presentation
  per injected tool at its boundary — the declaration, then
  `experimental_statusLabels` for the label, then a generic label and the
  plugin's branding glyph (or Toolbox) — and puts it on the `DynamicTool`
  definition it hands to the bridge. The built-in
  update_environment_directory tool states its own.
- `DynamicTool.presentation` rides the daemon wire on every session
  construction; HOST_DAEMON_PROTOCOL_VERSION 148 → 149 (the strict command
  schemas refuse the new field on an old daemon). Optional while the grammar
  migrates (A1): every committed recording's runtime lane predates the field
  and must keep replaying, and a bridge presents such a definition
  generically. The stabilization pass makes it required.
- The codex bridge learns the session's injected tools at construction and
  emits a `dynamicToolCall` to one of them as `{ server: "bb", tool }` with
  the definition's presentation; codex's own dynamic tools keep the generic
  presentation. The ask-user-question and workflows plugins declare theirs
  (AskUserQuestion and bb_workflow_result collapse by default).

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

Codex's Goal and its macOS permission profile are codex vocabulary, not
core's (docs/provider-plugin-api.md §3, genericity rule). The codex plugin
now declares them (`experimental_extensionKinds`, schemas in
plugins/provider-codex/src/extension-kinds.ts) under its plugin id —
`provider-codex/goal` (thread state) and `provider-codex/macos-permission`
(item) — and the server validates every live payload against those schemas
at ingest.

- The bridge emits thread/goal/updated as a `provider-codex/goal` extension
  state snapshot and thread/goal/cleared as the same kind with a null
  payload (latest snapshot wins).
- A command approval that asks for macOS capabilities no longer fails to
  decode: the approval reaches the user for the command (with the grantable
  network/file-system session grant), and the macOS profile rides the
  timeline as a `provider-codex/macos-permission` item whose presentation
  says what was asked and that bb cannot grant it. The full plugin-rendered
  approval is the interactions workstream's.
- Read-time conversion in core: persisted `thread/goal/updated` and
  `thread/goal/cleared` rows (721 + 222 in the production corpus) decode
  into the extension state in parseStoredThreadEvent, the one path every
  stored-event read takes, so no consumer sees the legacy type and old
  threads keep their goal card. The thread-view goal extraction and the
  runtime's goal-clear wait read that state.
- The sidebar's latest-goal lookup becomes a latest-thread-state lookup by
  kind: the partial index widens to thread/extensionState/updated rows
  (migration 0105, idempotent like 0092), the query filters the kind with
  json_extract, and legacy goal rows still count for the goal kind.
- The scripted echo bridge's thread/goal/clear models codex's cleared state
  the same way.

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

Every speaker has migrated, so the grammar members only the codex bridge
ever spoke go:

- `thread.goal` and `thread.goalCleared` leave the thread/delta grammar and
  the assembler; codex goals ride `extension.state` (`provider-codex/goal`)
  and the persisted `thread/goal/*` types stay read-only history that
  converts on read. Dropped under the grammar range the way WS1a dropped the
  v2 dialects — all bridges are in-repo and the assembler speaks v3 only —
  so PROVIDER_BRIDGE_PROTOCOL_VERSION stays 2; the G3 snapshot is updated
  and its header names the stabilization pass (presentation required) as
  the tightening that bumps.
- The `thread/openWork` notification leaves the protocol, the adapter's
  level-triggered per-thread set, and the reaper's adapter probe. Open work
  is what the timeline says: the runtime's background-work state counts a
  pending `delegation` exactly as it counts a background task, and the codex
  bridge settles its delegations when the child dies. A runtime ignores the
  notification from a pre-migration bridge, as it ignores any unknown one.
- The codex bridge drops its open-work reporting and the translator its
  local predicate (the child-exit suite now asserts the delegation close on
  the wire), and the hard-coded AskUserQuestion presentation: a bb-injected
  tool's definition carries its own.

Kept on purpose: the bridge's knowledge of codex's spawn/resume collab verbs
for a call whose receiver is not known yet (the next child turn on the
multiplexed root thread belongs to it). Tool-name knowledge lives in the
bridge; that is the point of the migration, not a v2 remnant.

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

recordings/parity-allowlist.json names every byte-inequivalence this PR
intends against origin/main, per cell, with the reason — 26 entries, all
scoped to provider codex (claude-code and acp-cursor replay with zero diffs):

- events /*/item/presentation on the 15 cells that have items — the new
  presentation field, items otherwise byte-identical;
- events /*/item/server on user-question — server "bb" on the bb-injected
  AskUserQuestion call;
- subagent events /6 and /28 and rows /0/children/0/{toolName,output} — the
  native sub-agent spawn is a delegation item, not a spawnAgent tool item;
- one event index per goal-bearing cell (archived-resume, compaction,
  missing-rollout, resume, steer, stop-interrupt) — thread/goal/cleared is
  the provider-codex/goal extension state with a null payload.

The codex bridge→runtime.current.ndjson lanes are re-recorded against this
bridge with `pnpm rerecord --provider codex --plan-with <main checkout>`
(#2153's mechanism); the recorded lanes and the other providers' current
lanes are untouched. Row-count pins unchanged; self-suite 43/43.

Co-Authored-By: Claude <noreply@anthropic.com>
@SawyerHood
SawyerHood force-pushed the bb/ws1b-codex-codex-bridge-to-v3-stack-on-2136-thr_xnid5ftd87 branch from 58d5a58 to 8bef1b0 Compare August 21, 2026 10:50
@SawyerHood
SawyerHood merged commit fcf9e61 into main Aug 21, 2026
13 checks passed
@SawyerHood
SawyerHood deleted the bb/ws1b-codex-codex-bridge-to-v3-stack-on-2136-thr_xnid5ftd87 branch August 21, 2026 14:20
SawyerHood added a commit that referenced this pull request Aug 21, 2026
…rpus row snapshots, timeline perf, permission matrix (#2121)

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

## What was wrong

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

## What changed

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

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

### Permission matrix (runtime)

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

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

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

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

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

Observations (pinned, not fixed):

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

## How you verified

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

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

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

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

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

---------

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

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

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

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

## What was wrong

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

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

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

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

## Regression oracle

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

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

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

## Not done

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

> AGENT GENERATED: by Claude Opus 5

---------

Co-authored-by: Claude <noreply@anthropic.com>
SawyerHood added a commit that referenced this pull request Aug 21, 2026
…on (#2179)

> **Stacked on #2178 (WS1b-claude).** Chain: contract → WS1a → WS2a →
codex #2164 → claude #2178 → **this PR** (6 commits above claude head
`61a0a9656`). Do not merge before the chain beneath it lands.

## What was wrong

The ACP bridge threw away the agent's native tool-call kind (`read`,
`edit`, `execute`, `search`, `fetch`, `think`, `other`) and wrote the
human `title` into the `tool` slot of a generic `tool` item
(`provider-acp/src/delta-translation.ts`, the old
`classifyAcpToolCall`). In the production database that is ~1,100 ACP
rows whose "tool name" is ``Read `/path/to/file` ``, plus `Web search:`,
`Updating plan`, `MCP: tool`, and `tool`. Generic tool permissions were
faked as `command` approvals (`interactions.ts`) because that was the
one subject with free text. No ACP item carried a presentation.

## What changed

One commit per layer; the last deletes.

1. **Presentation on every item** —
`plugins/provider-acp/src/presentation.ts`: a label pair and host glyph
per native kind, commands with the agent's Markdown code ticks stripped
from the headline, file changes by verb (write/edit/delete) with their
file names, plan snapshots, compactions. Every `item.open`/`item.close`
carries it.
2. **Native kind → core kinds; title → `presentation.title`** —
`plugins/provider-acp/src/tool-classification.ts`: `execute` →
`command`, diff / `edit` / `delete` with a path → `fileChange` (the
shared operation classifier the permission mapping uses is untouched,
#1803), `read` → `fileRead` when the path is known (`locations`, a
`rawInput` path field, or a single code-ticked token in the title — how
grok titles its reads), `search` → `search` when `rawInput` has a
pattern/query/regex (content) or glob (path), `fetch` → `webFetch` when
`rawInput` or the title carries the URL, `think` → `reasoning`.
Everything else (`other`, `move`, no kind, or a kind whose shape the
agent left unfilled — Cursor's `read`/`fetch` arrive with an empty
`rawInput` and no `locations`) is a generic `tool` whose `tool` slot is
the kind and whose presentation is the kind's label with the agent's
title as headline. A row is never a `fileRead` without a path. ACP
`plan` updates become settled `planSteps` snapshots (channel-keyed,
collapsed by default, read by the todo banner) instead of `turn.plan`.
3. **bb-injected tools (Q31)** — the translator learns the session's
`dynamicTools` at construction and binds each call the MCP proxy
forwards (`forwardDynamicToolCall` → `noteInjectedToolCall`) to the
agent's announcement. ACP gives no id linking the two (Cursor announces
every MCP call as `"MCP: tool"`, kind `other`), so the binding is
positional: the unbound `other` candidate whose title names the tool,
else one that mentions MCP, else the oldest; a proxied call with no
candidate open waits for the next announcement. A bound call's open or
close reads `{ server: "bb", tool, presentation }` from the
`DynamicTool` definition (generic under bb's glyph when the definition
predates the field); the close re-states it, so late binding still
settles as the bb tool.
4. **`tool_use` approval subject** — `interactions.ts` raises `{ kind:
"tool_use", itemId, tool, presentation }` for every permission that is
neither a command nor a file change, with the same classification the
timeline row gets. `apps/server/.../pending-interaction-timeline.ts`
stops throwing on the subject: a tool-use approval has no timeline item
of its own (the agent's own `tool_call` row is the record; the banner
renders the presentation) until WS5's interaction-lifecycle event. The
runtime conformance harness validates the subject instead of throwing;
the "no producer yet" comments on the client renderers are updated.
5. **Delete the ACP v2 translation path** — ACP was the last `turn.plan`
speaker, so the grammar member leaves `thread-delta.ts` and the
assembler under the grammar range (the way #2164 dropped
`thread.goal`/`thread/openWork`); `PROVIDER_BRIDGE_PROTOCOL_VERSION`
stays 2, the G3 snapshot loses the entry. The persisted
`turn/plan/updated` type stays as read-only history the view already
ignores.

**Wire:** `HOST_DAEMON_PROTOCOL_VERSION` stays at 150. Nothing this PR
sends between server and daemon changed shape: `tool_use` has been in
the pending-interaction schema since the contract PR, and
`DynamicTool.presentation` rode the wire since #2164.

**Interim UX to know about:** until the presentation-driven projection
lands, the legacy tool row shows the `tool` slot as its name, so a
generic ACP row reads `other`/`read`/`fetch` where it used to read the
agent's title (the title is in the persisted `presentation.title`, which
that projection will read). A denied `tool_use` approval leaves no
denied row in the timeline (the old fake command did); the agent's own
`tool_call_update` (usually `failed`) is the record.

## How you verified

Typecheck (turbo, `--concurrency 4`): `bb-plugin-provider-acp`,
`bb-plugin-provider-codex`, `bb-plugin-provider-claude-code`,
`@bb/agent-runtime`, `@bb/server`, `@bb/provider-bridge-protocol`,
`@get-bb/plugin-sdk`. npm version guard: PASS (0.4.11). eslint +
prettier on every changed file.

Tests: `bb-plugin-provider-acp` 196/196 (new: presentation coverage,
native-kind mapping incl. title-path/URL recovery and the unfilled-shape
fallbacks, bb-injected binding in Cursor order / proxy-first order /
by-name / never-a-command, `tool_use` subjects incl. bound bb tool and
in-flight-call headline); `@bb/provider-bridge-protocol` 217/217;
`@bb/agent-runtime` 439/439; `@bb/thread-view` 385/385; `@bb/domain`,
`@bb/core-ui`, `@get-bb/plugin-sdk`; `@bb/server` pending-interactions /
internal-interactive-requests / public-thread-interactions (65/65, new:
tool_use approval registers, appends no item event, resolves with deny).

**Conformance (acp-cursor):** scripted protocol suite against the fake
agent: pass. Recorded conformance: all 11 committed cells replayed
through this bridge (10 replayable + process-scoped model-list), every
rule passes.

**Parity A2** (`pnpm parity --old ~/.bb/parity-old-main --new .
--provider <id>`; old = `origin/main` f6fb434):
- acp-cursor **10 passed, 0 failed, 1 skipped**, zero unlisted, zero
stale. Allowlist entries (17, all `#2179`):
- `events /*/item/presentation` × approval-allow, approval-deny, steer,
stop-interrupt, subagent, turn-tools, user-question, web-search —
presentation is new; items otherwise byte-identical.
- `events /*/item/tool` × subagent, turn-tools, user-question,
web-search — `"Task: Subagent task"`→`other`, `"Read File"`→`read`,
`"Edit File"`→`edit`, `"MCP: tool"`→`other`, `"Web Fetch"`→`fetch`; the
title now rides `presentation.title`.
- `rows /0/children/1/toolName` × subagent, turn-tools, user-question,
web-search and `rows /0/children/3/toolName` × turn-tools — the legacy
row projects the tool slot.
- codex **16 passed, 0 failed, 1 skipped** — zero new diffs, zero stale.
- claude-code **13 passed, 0 failed, 1 skipped** — zero new diffs, zero
stale.
- acp-cursor `bridge→runtime.current` lanes re-recorded (`pnpm rerecord
--provider acp-cursor --plan-with ~/.bb/parity-old-main`); parity
self-suite 43/43; `row-counts.json` pins unchanged.

**Corpus A4:** `BB_PROVIDER_CORPUS_SNAPSHOT=compare` → 307/307 threads,
93,262 rows, `diffThreads: []`, `allowedDiffs: 0`; timeline-perf gate
21/21 (load 4.4 on 16 cores). **A4 cannot prove ACP:** the extracted
corpus is codex (166) + claude-code (141) only; the 29 ACP production
threads (acp-grok 25, acp-cursor 2, acp-pi-acp 2) were not extracted.
What A4 proves here is that this PR does not touch codex or claude rows.

**G11:** acp-cursor `provider/unhandled` 0 → 0 in every cell (pins
unchanged); the one pre-existing grammar drop in turn-tools is 1 → 1.
The production ACP unhandled rows (155, all grok: `tool_call_update` 83,
`agent_message_chunk` 37, `agent_thought_chunk` 35) are
outside-turn/visibility residue the kind mapping does not touch; no ACP
corpus exists to re-measure them.

## What the recordings cannot prove (needs live QA)

- **The native-kind → core-kind class is not in any recording.**
Cursor's `read`/`fetch` calls carry no path/URL, so in acp-cursor every
such row stays a generic `tool` (the `/*/item/tool` class above).
`fileRead`, `webFetch`, `search`, `reasoning` from ACP are covered by
unit tests only. The "every read/edit/execute row changes kind" class
the spec predicted did not materialize on this agent; grok (which titles
reads with the path) is the agent that will exercise it.
- **Q31 binding is not replayable.** The recorded user-question cell had
1 `item/tool/call`; a replay has 0 because the MCP proxy socket is not
part of a recording, so the replayed row is `other`, not
`ask_user_question`. The positional binding is unit-tested in both
orders; live QA with Cursor + ask-user-question is needed.
- **`tool_use` is not in any recording:** both approval cells are
`execute` (command subject). Unit tests + the server lifecycle test
cover it; no generic-permission cell exists for acp.
- **No plan-mode, compaction, or auth-failure cell for acp** (not
capturable at recording time); **fork is recorded as the agent's
refusal** (`fork: "tip"`, 0 events) and stays that way.
- The positional Q31 binding is a heuristic: two parallel MCP calls
announced in a different order than they execute would swap labels
(never the flow).

> AGENT GENERATED: by Claude Opus 5

---------

Co-authored-by: Claude <noreply@anthropic.com>
SawyerHood added a commit that referenced this pull request Aug 21, 2026
)

## What was wrong

Codex labels each reconnect attempt with a structured `codexErrorInfo`
(for example `{ responseStreamDisconnected: { httpStatusCode } }`,
`willRetry: true`, failure text in `additionalDetails`), then reports
the terminal failure for the same stream error with `codexErrorInfo:
"other"` and the failure text moved to `message`. That downgrade is
upstream: codex-rs `notify_stream_error` always labels retries
`ResponseStreamDisconnected`, while `CodexErr::to_codex_protocol_error`
maps `CodexErrorDetails::Stream` to `CodexErrorInfo::Other`. The bridge
trusted the terminal value, so the final timeline row lost the
`stream-disconnected` category and rendered as a generic **Provider
error** (the detail text is longer than the 80-char title budget, so the
disconnect cause was only visible after expanding the row).

Issue: #1840. The independent report URL
(https://get-bb.github.io/reports/issues/1840.html) returns 404; the
issue body and #1563 carry the repro.

## What changed

- `plugins/provider-codex/src/delta-translation.ts`: the translation
state remembers the retry-time `codexErrorInfo` and failure text per
codex `threadId\0turnId`. A terminal (`willRetry: false`) error whose
`codexErrorInfo` is `other` and whose failure text equals the remembered
retry text reuses the retry classification. The context is consumed by
the terminal error, dropped on `turn/completed`, and never crosses
turns. Unrelated terminal errors and every non-`other` terminal value
keep the provider-reported classification. No provider prose is parsed.
- `plugins/provider-codex/src/translator.ts`: `thread/closed` also
clears the retry context for that codex thread (exported
`clearCodexEventTranslationThreadState`).
- No wire change between server and host daemon: the `provider/error`
event shape is unchanged, only the value of `errorInfo` on this one
path. No CLI or doc surface changes.

Relation to #1563: that PR (same design, by @ymichael and @brsbl)
targets `plugins/provider-codex/src/event-translation.ts`, which #1834
deleted when it moved Codex onto the narrow-grammar delta translator. It
no longer merges (`git merge-tree` reports a content conflict in
`delta-translation.ts`). This PR ports that design onto
`delta-translation.ts`, with a flat `Map` keyed by thread+turn instead
of nested maps.

## How you verified

Tests added in `plugins/provider-codex/src/translator.test.ts` (`codex
terminal retry-error classification`):

- carries the retry classification into the degraded terminal error (and
the context is consumed, so a repeat stays `other`)
- does not relabel an unrelated terminal error after a reconnect
- scopes the retry context to the turn and drops it on `turn/completed`
- drops the retry context when the codex thread closes

Fail-before: with `delta-translation.ts` and `translator.ts` restored
from `origin/main`, `pnpm exec turbo run test
--filter=bb-plugin-provider-codex --force -- --run
src/translator.test.ts` fails the first test:

```
× carries the retry classification into the degraded terminal error
AssertionError: expected [ { type: 'provider/error', …(7) } ] to deep equally contain ObjectContaining{…}
  expected "category": "stream-disconnected", "providerCode": "responseStreamDisconnected"
  received "category": "unknown", "providerCode": "other"
```

The three negative tests pass on `origin/main` as expected (they pin
that the guard does not over-apply).

Pass-after, from the committed tree (`git status --porcelain` empty):

- `pnpm exec turbo run typecheck test --filter=bb-plugin-provider-codex`
→ `Tasks: 7 successful, 7 total` (16 test files, 176 tests passed)
- `pnpm exec turbo run build` → `Tasks: 18 successful, 18 total`

Manual replay of the incident's two events (reconnect with
`responseStreamDisconnected`, then terminal `other` with the same
`stream disconnected before completion: ...` text) through
`createCodexEventTranslator` via `node --conditions=source --import
tsx`: the terminal delta now carries `errorInfo: { category:
"stream-disconnected", providerCode: "responseStreamDisconnected",
httpStatusCode: null }`.


## Rebase

Rebased onto `origin/main` after the grammar-v3 bridge stack landed
(#2124, #2136, #2153, #2148, #2164). That stack rewrote
`delta-translation.ts` (presentation on every item,
`injectedToolsByName` on the translation state, delegation items) and
`translator.ts` (`clearClosedThreadState` now returns the closes for
open delegations), but it did not touch the `error` case,
`toProviderErrorInfo`, or `turn/completed`, so the fix maps onto the new
code unchanged:

- The only textual conflict was in `CodexEventTranslationState` /
`createCodexEventTranslationState`, where main added
`injectedToolsByName` next to where this PR adds `retryErrorsByTurnKey`.
Resolved by keeping both fields.
- `clearCodexEventTranslationThreadState` is still called from
`clearClosedThreadState` in `translator.ts`, before it returns the
delegation closes that main added.
- The tests merged cleanly into `translator.test.ts`; they run through
the grammar-v3 `createDeltaAssembler` harness on main, and the
`provider/error` event shape they assert is unchanged.

Re-verified on the new base (`798b720ef`, one commit on top of
`origin/main`):

- Fail-before: with `delta-translation.ts` and `translator.ts` restored
from `origin/main`, `pnpm exec turbo run test
--filter=bb-plugin-provider-codex --force -- --run
src/translator.test.ts -t "codex terminal retry-error classification"` →
`1 failed | 3 passed`; `carries the retry classification into the
degraded terminal error` fails with expected `"category":
"stream-disconnected", "providerCode": "responseStreamDisconnected"`,
received `"category": "unknown", "providerCode": "other"`. The bug is
still present on current main.
- Pass-after, from the committed tree (`git status --porcelain` empty):
`pnpm exec turbo run typecheck test --filter=bb-plugin-provider-codex
--force` → `Tasks: 7 successful, 7 total`, `Test Files 18 passed (18)`,
`Tests 197 passed (197)`. `pnpm exec turbo run build` → `Tasks: 18
successful, 18 total`.

Fixes #1840

> AGENT GENERATED: by Claude Opus 5


## Independent verification

Verified on a fresh checkout of `bb/fix-1840-codex-stream-disconnect`
(282f32e, one commit on top of `origin/main`; `git merge-base
--is-ancestor origin/main HEAD` true, GitHub reports MERGEABLE).

Root cause checked against current upstream sources (not from the PR
description): `codex-rs/core/src/session/mod.rs` `notify_stream_error`
hard-codes `CodexErrorInfo::ResponseStreamDisconnected` for every retry
notification, `codex-rs/core/src/responses_retry.rs` returns the raw
`CodexErr` once retries are exhausted, and
`codex-rs/protocol/src/error.rs` `to_codex_protocol_error` has no arm
for `CodexErrorDetails::Stream` so it hits `_ => CodexErrorInfo::Other`.
The app-server maps `EventMsg::StreamError` to `error` with `willRetry:
true` plus `additionalDetails`, and `EventMsg::Error` to `willRetry:
false` with `additional_details: None`. The PR's correlation (retry
`additionalDetails` vs terminal `message`, same thread+turn, `other`
only) matches that wire shape exactly.

Commands:

- `pnpm install --frozen-lockfile --prefer-offline` and `pnpm exec turbo
run build` (18/18).
- Fail-before: `git checkout origin/main --
plugins/provider-codex/src/delta-translation.ts
plugins/provider-codex/src/translator.ts`, then `pnpm exec turbo run
test --filter=bb-plugin-provider-codex --force -- --run
src/translator.test.ts -t "codex terminal retry-error classification"`:
1 failed, 3 passed. Failing assertion: `carries the retry classification
into the degraded terminal error` -> `AssertionError: expected [ { type:
'provider/error', ...(7) } ] to deep equally contain
ObjectContaining{...}`, expected `"category": "stream-disconnected",
"providerCode": "responseStreamDisconnected", "httpStatusCode": 502`,
received `"category": "unknown", "providerCode": "other",
"httpStatusCode": null`.
- Pass-after: restored the PR sources (`git status --porcelain` empty),
`pnpm exec turbo run typecheck test --filter=bb-plugin-provider-codex
--force` -> `Tasks: 7 successful, 7 total`, `Test Files 16 passed (16)`,
`Tests 176 passed (176)`.
- Only `packages/thread-view/src/error-display.ts` consumes the
`stream-disconnected` category (title text); no runtime recovery keys on
it, so the blast radius is the timeline row title.

Repro on the fixed branch: a real Codex stream outage cannot be
triggered deterministically here, so I replayed upstream-shaped events
through `createCodexEventTranslator` directly (`node --conditions=source
--import tsx`): four `Reconnecting... n/5` retries labelled
`responseStreamDisconnected` with the failure text in
`additionalDetails`, then a terminal `other` with that text as `message`
and `additionalDetails: null`. Fixed branch:
`{"category":"stream-disconnected","providerCode":"responseStreamDisconnected","httpStatusCode":null}`.
Same script with `origin/main` sources:
`{"category":"unknown","providerCode":"other"}`. Extra negative replays
on the fixed branch all stayed correct: unrelated terminal text after a
retry stays `unknown`; a structured terminal value
(`responseTooManyFailedAttempts`, 503) is never overridden by the
remembered retry; a thread-scoped retry (no `turnId`) does not relabel a
turn-scoped terminal; a retry on a different codex thread does not leak
across threads.

CI: all checks pass (Checks, Package Smoke ubuntu+macos, Tests
app-1/2/3, integration, server, packages, version check).

Residual risks (minor, not blocking): the correlation needs the terminal
`message` to equal the last notified retry's `additionalDetails`; the
terminal `CodexErr` is the attempt after the last notified retry, so if
the inner reqwest text differs between attempts the row falls back to
today's generic label (never to a wrong one). Retry context for a turn
whose child dies without `thread/closed` or `turn/completed` lives in
the per-session translator until the session is released (a few bytes).
The upstream mapping gap in codex-rs remains.

> AGENT GENERATED: by Claude Opus 5




## Independent verification (post-rebase)

Re-verified after the rebase onto the grammar-v3 bridge. Checked out
`798b720ef` (one commit on top of `cf00cfe06`); `origin/main` had since
gained #2120 (provider-literal ratchet), which merges cleanly and
excludes `plugins/provider-*`, so it cannot affect this PR (`node
scripts/check-provider-literal-ratchet.mjs --base origin/main` ->
`ratchet OK: 148 references across 40 core files`).

Fix still targets the right code path in the rewritten translator: the
v3 stack left the `error` case, `toProviderErrorInfo`, and
`turn/completed` unchanged; `clearCodexEventTranslationThreadState` runs
inside `clearClosedThreadState` before the delegation closes it now
returns; `translateEvent` still routes `error` events through
`translateCodexEventToDeltas(event, eventTranslationState)` with the
single per-session state. Nothing downstream reclassifies:
`@bb/agent-runtime` `shouldRestartCodexThreadAfterEvent` keys only on
`rate-limit`/`unauthorized` categories (a retry-time label is always
`stream-disconnected`, so restart policy is unchanged) and
`packages/thread-view/src/error-display.ts` is the only consumer of the
category.

Commands (all from the committed tree):

- Fail-before on current main source: `git checkout origin/main --
plugins/provider-codex/src/delta-translation.ts
plugins/provider-codex/src/translator.ts`, then `pnpm exec vitest run
src/translator.test.ts -t "codex terminal retry-error classification"`
in `plugins/provider-codex` -> `1 failed | 3 passed`; `carries the retry
classification into the degraded terminal error` fails with expected
`"category": "stream-disconnected", "providerCode":
"responseStreamDisconnected", "httpStatusCode": 502`, received
`"category": "unknown", "providerCode": "other", "httpStatusCode": null`
(`src/translator.test.ts:1272`).
- Pass-after (sources restored, `git status --porcelain` empty): same
vitest command -> `4 passed`. `pnpm exec turbo run typecheck test
--filter=bb-plugin-provider-codex --force` -> `Tasks: 7 successful, 7
total`, `Test Files 18 passed (18)`, `Tests 197 passed (197)`.
- Dependents: `pnpm exec turbo run typecheck test
--filter=@bb/provider-parity --filter=@bb/agent-runtime
--filter=@bb/provider-bridge-protocol --force` -> `Tasks: 10 successful,
10 total` (parity 43 passed, agent-runtime 439 passed, bridge-protocol
217 passed). The parity suite replays every committed recording,
including `recordings/codex/auth-failure`, through the current bridge
with zero diffs.
- `pnpm exec prettier --check` and `pnpm exec eslint` on the three
touched files: clean.

Repro on the fixed branch: replayed the issue's event sequence (four
`Reconnecting... n/5` retries labelled `responseStreamDisconnected` with
the failure text in `additionalDetails`, then terminal `other` with that
text as `message`) through `createCodexEventTranslator` via `node
--conditions=source --import tsx`: terminal delta is
`{"category":"stream-disconnected","providerCode":"responseStreamDisconnected","httpStatusCode":null}`.
A terminal-only event with no preceding retry stays `unknown`/`other`
(by design). Cross-checked against upstream
`codex-rs/protocol/src/error.rs` (`Stream(..)` still falls to `_ =>
CodexErrorInfo::Other`; terminal `message` is `self.to_string()`, the
same text `notify_stream_error` puts in `additional_details`).

CI: all 11 check-runs on `798b720ef` succeed (Checks, Package Smoke
ubuntu+macos, Tests app-1/2/3, integration, server, packages, version
check x2); 2 skipped (node-compat smoke, iOS flows).

Residual risk (minor, not blocking): the real recorded `auth-failure`
cell shows Codex's failure text can carry per-request `cf-ray`/`request
id` values that differ between the last retry and the terminal attempt;
there the exact-text guard does not fire and the terminal row stays the
generic label exactly as on main (for that 401 case a
`stream-disconnected` label would arguably be wrong anyway, and the
runtime's 401 restart text pattern still matches). Retry context for
errors without a `turnId` is only dropped on `thread/closed`.

> AGENT GENERATED: by Claude Opus 5

Co-authored-by: Claude <noreply@anthropic.com>
SawyerHood added a commit that referenced this pull request Aug 21, 2026
## What was wrong

`bb plugin reload` reported success for a reload that left the plugin
unusable. `PluginService.reload()` returned `void`, `POST
/api/v1/plugins/reload` answered `{ ok: true }` unconditionally, and the
CLI exited 1 only on `ok: false` or an unknown id. So when a background
service ignored its abort signal, the runtime correctly refused to
double-start it, marked the plugin `degraded`, closed the old instance's
database handles, and unloaded it (its `bb` command gone, the orphaned
service ticking `The database connection is not open` every second), and
the reload still returned exit 0 / `ok: true`. The same held when the
new sources failed to load and the runtime kept the previous instance
(`running` with `reload failed: …`). Issue: #2029 (defect 2). Report:
https://get-bb.github.io/reports/issues/2029.html

Defect 1 in the issue (the host rebuilding `dist/app.*` into a
path-installed plugin root) is the documented path-install cache policy
(`isMutableAppBundleStale`, covered by existing tests) and is tracked by
#1863; this PR does not change it.

## What changed

- `apps/server/src/services/plugins/plugin-runtime.ts`: `loadOne(row)`
resolves the load problem (`string | null`) instead of `void`. Null
means the row's current sources are running now, or the plugin stays
disabled by the user's switch. Every failure exit returns its reason; a
reload that kept the previous instance says so (`… (the previous
instance is still running)`); the two hung-service exits share
`hungServicesDetail`. Statuses and log lines are unchanged.
- `apps/server/src/services/plugins/plugin-service.ts`: `reload(id?)`
returns `PluginReloadOutcome`: `{ ok: true, plugins }` or `{ ok: false,
error, plugins }` where `error` lists each targeted plugin that did not
come up and `plugins` is the inventory after the reload. The builtin
source-watcher's `reloadPlugin` throws the same problem so the dev loop
logs `reload failed: …` instead of `reloaded`.
- `apps/server/src/routes/plugins.ts`: the route answers `422 { ok:
false, error, plugins }` on failure; success body is unchanged
(`pluginReloadResponseSchema` still matches). SDK callers (web
`PluginHealthBanner`, mobile `useReloadPlugins`) already surface a
thrown `BbHttpError` with the server's message. No host daemon wire
change, so no `HOST_DAEMON_PROTOCOL_VERSION` bump.
- `apps/cli/src/commands/plugin.ts`: `bb plugin reload` prints the
targeted entries (status and detail) before the error and exit 1;
`--json` already exited 1 on `ok: false`. `bb plugin dev` already throws
on `!ok` and its loop logs it.
- Guide (`packages/templates/src/templates/bb-guide-plugins.md`) and
bb-cli skill
(`apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md`):
document the exit code.
- `plugin-registration.ts`, `plugin-activation.ts`: `loadOne` dep type
follows.

## How you verified

Tests that fail on `origin/main` and pass here:

- `apps/server/test/services/plugins/plugin-background.test.ts`
- `marks the plugin degraded when a service ignores its abort` now
asserts the outcome. Before: `AssertionError: expected undefined to
deeply equal { ok: false, …(2) }`.
- `reports a failed reload that kept the previous instance` (new):
broken edit → `running` + `reload failed: boom on load`, outcome `ok:
false` with `plugin "keeper" reload failed: boom on load (the previous
instance is still running)`; reload-all reports it; a fixed edit reloads
`ok: true`.
- `apps/server/test/services/plugins/plugin-reload-route.test.ts` (new,
real app harness): success → 200 `{ ok: true, plugins }`; broken sources
→ 422 `{ ok: false, error, plugins: [{ status: "running", statusDetail:
"reload failed: boom on load" }] }`. Before: `AssertionError: expected
200 to be 422`.

Commands (from the committed tree):

- `pnpm exec turbo run typecheck --filter=@bb/server --filter=@bb/cli
--filter=@bb/templates` → `Tasks: 6 successful, 6 total`
- `pnpm exec turbo run test --filter=@bb/cli --filter=@bb/templates` →
`Tasks: 8 successful, 8 total` (453 + 41 tests)
- `pnpm exec turbo run test --filter=@bb/server` → 1825 passed, 1
failed: `test/internal/internal-skill-trees.test.ts` (`mode: 420` vs
`436`), the known local umask-0002 failure unrelated to this change; it
passes in CI.

Manual repro on my own dev instance with the report's fixture (a path
plugin whose `lane-watcher` service captures `bb.storage.database()` and
ignores abort):

```
$ bb plugin reload collab-fixture
collab-fixture@0.1.0  degraded  (service lane-watcher did not stop)
  source: path:/home/sawyer/.bb-dev/scratch/2029/bb-plugin-collab
plugin "collab-fixture" reload failed: service lane-watcher did not stop
exit=1
$ bb plugin reload --json collab-fixture   # "ok": false, same error, exit=1
$ curl -X POST $BB_SERVER_URL/api/v1/plugins/reload?id=collab-fixture   # HTTP 422
```

Before this change the same reload printed the degraded entry and exited
0 with `"ok": true` (report step 3).

Fixes #2029

> AGENT GENERATED: by Claude Opus 5



## Independent verification

Checked out `bb/fix-2029-plugin-reload-orphans` (8d19397, one commit
on top of origin/main f6fb434; origin/main has not moved since) in a
separate worktree and dev instance.

Commands:

- `pnpm install --frozen-lockfile --prefer-offline && pnpm exec turbo
run build` (18/18 tasks).
- Fail-before: `git checkout origin/main --
apps/cli/src/commands/plugin.ts apps/server/src/routes/plugins.ts
apps/server/src/services/plugins/{plugin-activation,plugin-registration,plugin-runtime,plugin-service}.ts`,
then `pnpm exec vitest run
test/services/plugins/plugin-background.test.ts
test/services/plugins/plugin-reload-route.test.ts` from `apps/server`: 3
failed / 15 passed.
- `marks the plugin degraded when a service ignores its abort`:
`AssertionError: expected undefined to deeply equal { ok: false, …(2) }`
- `reports a failed reload that kept the previous instance`: `TypeError:
Cannot read properties of undefined (reading 'ok')` at
`expect(healthy.ok).toBe(true)`
- `answers ok:false with the load problem when the new sources did not
load (#2029)`: `AssertionError: expected 200 to be 422`
- Pass-after: `git checkout HEAD -- <same files>`, same vitest run:
18/18 passed.
- `pnpm exec turbo run typecheck --filter=@bb/server --filter=@bb/cli
--filter=@bb/templates`: `Tasks: 6 successful, 6 total`.
- `pnpm exec turbo run test --filter=@bb/server --filter=@bb/cli
--filter=@bb/templates`: cli 453/453, templates 41/41, server 1825
passed / 1 failed (`test/internal/internal-skill-trees.test.ts` mode 420
vs 436, the known local umask-0002 failure; unrelated, green 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, report fixture: path plugin
whose `lane-watcher` service captures `bb.storage.database()` and
ignores abort):

```
$ bb plugin install <fixture> --yes      -> collab-fixture@0.1.0 running; bb collab -> "collab ok", exit=0
$ bb plugin reload collab-fixture
collab-fixture@0.1.0  degraded  (service lane-watcher did not stop)
  source: path:.../bb-plugin-collab
plugin "collab-fixture" reload failed: service lane-watcher did not stop
exit=1
$ bb plugin reload --json collab-fixture -> {"ok":false,"error":"plugin \"collab-fixture\" reload failed: service lane-watcher did not stop"}, exit=1
$ curl -X POST $BB_SERVER_URL/api/v1/plugins/reload?id=collab-fixture -> http=422
$ bb plugin reload                        -> exit=1, prints the degraded entry then the error
$ bb plugin reload automations            -> running, exit=0; raw POST -> http=200
```

On main the same reload exits 0 with `ok: true` (report step 3). The
orphaned service still logs `The database connection is not open` every
tick, which is the documented degraded contract and out of scope here.

Review notes: root cause (reload outcome never propagated) is fixed in
the server, which owns the policy; no host-daemon wire change, so no
protocol bump needed. CLI `callPlugins` already passes 422 bodies
through, the dev loop catches the thrown reload, and the SDK surfaces
the 422 as `BbHttpError` with the server message (web banner toasts,
mobile mutation toasts). Guide and bb-cli skill document the exit code.
Minor, non-blocking: `pluginReloadResponseSchema` in server-contract
still types only the success body, so SDK callers see the failure via
`BbHttpError.body` untyped; web `PluginHealthBanner` does not invalidate
the plugin list on error and relies on the `plugins-changed` broadcast
for the refreshed status. Defect 1 (rebuild into a path plugin root) is
intentionally left to #1863.

> AGENT GENERATED: by Claude Opus 5


## Rebase

Rebased onto origin/main `85eec4da6` (was 33 commits behind; still one
commit, now `c7cd438b3`). `git rebase origin/main` applied with no
conflicts. Main's changes in the touched files are orthogonal to this
fix: `plugin-runtime.ts` gained provider `installRank` and per-command
settings reads (#2148), `plugin-service.ts` gained agent-tool
presentation for grammar v3 (#2164), and the guide/skill docs gained
unrelated lines. None of that touches `loadOne`'s exits or `reload`, so
the `string | null` load problem and the `PluginReloadOutcome` plumbing
map onto the new code unchanged.

Re-proved on the new base. With the six non-test source files reverted
to origin/main, `plugin-background.test.ts` +
`plugin-reload-route.test.ts`: 3 failed / 15 passed (`expected undefined
to deeply equal { ok: false, …(2) }`, `Cannot read properties of
undefined (reading 'ok')`, `expected 200 to be 422`); restored: 18/18
passed. From the committed tree: `turbo typecheck --filter=@bb/server
--filter=@bb/cli --filter=@bb/templates --filter=@bb/host-daemon
--filter=@bb/sdk` → `Tasks: 8 successful, 8 total`; `turbo test
--filter=@bb/cli --filter=@bb/templates` → `Tasks: 8 successful, 8
total` (453 + 41 tests); `turbo test --filter=@bb/server` → 1898 passed,
2 failed: the known local umask `internal-skill-trees` failure and a 5s
timeout in `plugin-update.test.ts` (`waits one full interval…`) on a box
at load average 128 with swap full; that file passes 27/27 when run
alone.

> AGENT GENERATED: by Claude Opus 5



## Independent verification (post-rebase)

Re-verified the rebased head `c7cd438b3` (one commit; merge base with
origin/main is `75d6fc4d4`, and the only newer main commit, `85eec4da6`,
is a TestFlight CI change) in a fresh worktree and own dev instance.
`git merge --no-commit origin/main` is clean. Main's intervening changes
to `plugin-runtime.ts` (#2148 provider `installRank`, per-command
settings reads) and `plugin-service.ts` (#2164 grammar-v3 agent-tool
presentation) do not add any exit to `loadOne`; every exit in the
rebased `loadOne` returns its problem (`string | null`) and the final
path returns `null`. No provider-bridge-protocol, provider plugin, or
agent-runtime file is touched, so no parity run was needed and no
`HOST_DAEMON_PROTOCOL_VERSION` bump is required.

Commands:

- `pnpm install --frozen-lockfile --prefer-offline && pnpm exec turbo
run build` (18/18 tasks).
- Fail-before: `git checkout origin/main --
apps/cli/src/commands/plugin.ts apps/server/src/routes/plugins.ts
apps/server/src/services/plugins/{plugin-activation,plugin-registration,plugin-runtime,plugin-service}.ts`,
then `pnpm exec vitest run
test/services/plugins/plugin-background.test.ts
test/services/plugins/plugin-reload-route.test.ts` from `apps/server`: 3
failed / 15 passed.
- `marks the plugin degraded when a service ignores its abort`:
`AssertionError: expected undefined to deeply equal { ok: false, …(2) }`
- `reports a failed reload that kept the previous instance`: `TypeError:
Cannot read properties of undefined (reading 'ok')`
- `answers ok:false with the load problem when the new sources did not
load (#2029)`: `AssertionError: expected 200 to be 422`
- Pass-after: `git checkout HEAD -- <same files>`, same run: 18/18
passed.
- From the committed tree (`git status --porcelain` empty): `pnpm exec
turbo run typecheck --filter=@bb/server --filter=@bb/cli
--filter=@bb/templates --filter=@bb/sdk --filter=@bb/host-daemon` →
`Tasks: 8 successful, 8 total`.
- `pnpm exec turbo run test --filter=@bb/server --filter=@bb/cli
--filter=@bb/templates`: cli 453/453, templates 41/41, server 1899
passed / 1 failed (`test/internal/internal-skill-trees.test.ts` mode 420
vs 436, the known local umask-0002 failure; unrelated, green in CI).
- CI on `c7cd438b3`: all checks pass (Checks, Package Smoke ubuntu +
macos, Tests app-1/2/3, integration, packages, server, version check);
iOS simulator and Node compat smoke are conditional skips.

Repro on the rebased branch (own dev instance, report fixture: path
plugin whose `lane-watcher` captures `bb.storage.database()` and ignores
abort):

```
$ bb plugin install <fixture> --yes   -> collab-fixture@0.1.0 running; bb collab -> "collab ok", exit=0
$ bb plugin reload collab-fixture
plugin "collab-fixture" reload failed: service lane-watcher did not stop
exit=1
$ bb plugin reload --json collab-fixture -> {"ok":false,"error":"plugin \"collab-fixture\" reload failed: service lane-watcher did not stop", ...}, exit=1
$ curl -X POST $BB_SERVER_URL/api/v1/plugins/reload?id=collab-fixture -> http=422, same body
$ bb collab                            -> error: unknown command 'collab' (degraded, as designed)
$ bb plugin reload                     -> exit=1 (reports the degraded plugin)
$ bb plugin reload automations         -> running, exit=0; raw POST -> http=200
```

On main the same reload exits 0 with `ok: true` (report step 3).
Residual, unchanged from the first verification: the orphaned service
still logs `The database connection is not open` every tick (documented
degraded contract); `pluginReloadResponseSchema` types only the success
body, so SDK callers see the 422 as a `BbHttpError` with the server
message; defect 1 (rebuild into a path plugin root) is left to #1863.

> AGENT GENERATED: by Claude Opus 5

Co-authored-by: Claude <noreply@anthropic.com>
SawyerHood added a commit that referenced this pull request Aug 21, 2026
…2169)

## What was wrong

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

## What changed

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

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

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

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

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

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

## How you verified

Tests added:

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

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

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

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

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

Fixes #1302

> AGENT GENERATED: by Claude Opus 5


## Independent verification

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

Commands:

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

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

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

> AGENT GENERATED: by Claude Opus 5


## Rebase

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

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

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

> AGENT GENERATED: by Claude Opus 5




## Independent verification (guards)

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

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

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


## Rebase (2026-08-21)

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

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

> AGENT GENERATED: by Claude Opus 5

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant