Skip to content

Record Pi extension messages as provider input - #2154

Merged
SawyerHood merged 1 commit into
mainfrom
bb/fix-1681-pi-notification-wake
Aug 21, 2026
Merged

SawyerHood merged 1 commit into
mainfrom
bb/fix-1681-pi-notification-wake

Conversation

@SawyerHood

@SawyerHood SawyerHood commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

What was wrong

A Pi extension can inject a custom message and trigger a turn on its own (pi.sendMessage(..., { triggerTurn: true }); this is how @aliou/pi-processes wakes a thread when a background command finishes). Pi emits agent_start, then message_start/message_end with role: "custom" for that message. bb's Pi translator had no case for custom-role boundaries and its visibility metadata rated the role unknown, so both envelopes surfaced as provider/unhandled ("Unhandled Pi event" rows in dev builds or with the setting on), and the message itself was never recorded. The extension-triggered turn therefore showed an assistant answer with no input in front of it, in the app and in bb thread log. bb also had no grammar for provider-originated input at all: the narrow-grammar thread/delta has no delta for it, and nothing in @bb/thread-view projected a userMessage item.

The second half of the issue (Pi's agent_end.messages carrying string content, which stranded the turn as "Working...") already landed in #1663.

Issue: #1681. Report: https://get-bb.github.io/reports/issues/1681.html

PR #1682 attacks the same gap but was written against event-translation.ts, which #1834 replaced with delta-translation.ts; it no longer applies to main. This PR implements the equivalent on the narrow-grammar path and keeps the generic role: "custom" handling the report asked for.

What changed

  • packages/provider-bridge-protocol/src/thread-delta.ts: new input.provider delta (text, optional parentRef) for input the provider injected without a bb client request. Additive, so no bridge protocol version change; the G3 grammar guardrail snapshot (provider-bridge-grammar.v2.snapshot.json) gains the new kind.

  • packages/provider-bridge-protocol/src/assembler/delta-assembler.ts (moved there from @bb/agent-runtime on main): input.provider records an item/completed userMessage item (assembler-minted id) in the open turn; with no turn open it is dropped, because Pi appends idle attention: context notes to its own context without running the agent and there is no bb turn to attach them to.

  • packages/agent-runtime/src/pi/delta-translation.ts: parses message_start/message_end for role: "custom" (any customType, string or block-array content). A displayed message_start becomes input.provider; message_end, hidden messages, and empty text translate to nothing.

  • packages/agent-runtime/src/pi/visibility.ts: custom-role boundaries are noise, so the silent cases never reach the unhandled fallback.

  • packages/thread-view/src/user-message-parsing.ts, build-event-projection.ts: project the userMessage item as a system-initiated accepted steer of its turn. It renders as the existing "System Message" row in the app (inside the turn's "Worked for" group in summary mode) and as a User row in bb thread log. It is a steer, not a message, on purpose: the server pages the timeline on message rows backed by stored client/turn/requested events (timelineSegmentAnchorConditions in @bb/db vs isTimelineSegmentAnchorRow in timeline-pagination.ts). With a message row here the latest page silently dropped every earlier turn and reported hasOlderRows: false; I hit this live before switching.

  • HOST_DAEMON_PROTOCOL_VERSION 151 -> 152 (146 -> 147, then 150 -> 151, before two rebases onto a moving main): the daemon now sends a userMessage item it never emitted before. The shape already existed in the shared schema, so the bump is for the semantic change and to roll the fix to enrolled daemons.

  • CI guard: this PR no longer carries an @get-bb/plugin-sdk version change. thread-delta.ts and the assembler are bundled into the SDK's published provider-bridge entry points, so the npm version guard (check-npm-version-guard.mjs) needs an unpublished version; main has since moved the SDK to 0.4.13, which npm has not published (npm latest is 0.4.12), so this PR adopts main's version and the guard passes with no further bump.

Not done, deliberately: the issue's "empty assistant output produces an explicit warning". Nothing in bb promises that today and #1682's version never fired on real Pi (it assumed message_start before agent_start).

How you verified

New tests, all fail on origin/main source and pass with the fix:

  • packages/agent-runtime/src/pi/delta-translation.test.ts
    • "records a displayed Pi custom message as the input of the turn it triggered" (real order: agent_start -> message_start -> message_end). On main: AssertionError: expected [ Array(1) ] to deeply equal [ { type: 'item/completed', ... } ] with a provider/unhandled rawType: "sdk/message_start" received.
    • "joins the text blocks of an array-content Pi custom message"
    • "drops hidden and idle Pi custom messages without surfacing them as unhandled". On main: expected [ Array(1) ] to deeply equal [].
  • packages/thread-view/test/timeline-cli-rendering.snapshots.test.ts "shows provider-injected input as a system-initiated steer of its turn". On main: expected [ { initiator: 'user', ... } ] to deeply equal [ { initiator: 'user', ... }, ...(1) ] (no provider row projected).
  • apps/server/test/services/threads/timeline-provider-input.test.ts: latest page keeps the user's first turn, returnedSegmentCount: 1, hasOlderRows: false, provider input nested in turn 2. Fails if the row is projected as a message (first turn dropped: expected [ ...(2) ] to deeply equal [ 'user:Reply only with ok.', ...(3) ]).

Commands, run from the committed tree (git status --porcelain empty):

  • pnpm exec turbo run typecheck --filter=@bb/provider-bridge-protocol --filter=@bb/agent-runtime --filter=@bb/thread-view --filter=@bb/host-daemon-contract --filter=@bb/host-daemon --filter=@bb/server --filter=@bb/cli --filter=@bb/app --filter=bb-plugin-provider-acp --filter=bb-plugin-provider-codex --filter=bb-plugin-provider-claude-code -> Tasks: 15 successful, 15 total
  • pnpm exec turbo run test --filter=@bb/provider-bridge-protocol --filter=@bb/agent-runtime --filter=@bb/thread-view --filter=@bb/host-daemon-contract --filter=bb-plugin-provider-acp --filter=bb-plugin-provider-codex --filter=bb-plugin-provider-claude-code -> Tasks: 11 successful, 11 total (agent-runtime 31 files, thread-view 21 files, protocol 10 files)
  • pnpm exec turbo run test --filter=@bb/server -> 195/196 files pass; the one failure is internal-skill-trees.test.ts expecting file mode 0644 on a umask 0002 machine (pre-existing local-only failure, passes in CI). timeline-provider-input.test.ts passes.

Manual, on my own dev instance with the report's 30-line stand-in extension (same message shape as pi-processes 0.10.9, PI_CODING_AGENT_DIR pointing at a trust-listed copy of the Pi agent dir), real Pi session, prompt "Reply only with ok.":

16 turn/started        turn da6731fd37-t2
17 item/completed      turn da6731fd37-t2  userMessage [{"type":"text","text":"<process_event type=\"lifecycle\" kind=\"success\" process_id=\"proc_551c\" name=\"sleep-done\">Process completed ..."}]
18 item/started        turn da6731fd37-t2  agentMessage
...
21 item/completed      turn da6731fd37-t2  agentMessage "ok"
23 turn/completed      turn da6731fd37-t2  status=completed
thread status: idle

No provider/unhandled events (main produced two for sdk/message_start/sdk/message_end). bb thread log shows the process event as a User row before the ok. The app shows the first turn, then "Worked for 1s" containing a "System Message" row with the process event, then ok.

Fixes #1681

AGENT GENERATED: by Claude Opus 5

Independent verification

Verified by a second agent on a fresh checkout (git fetch origin bb/fix-1681-pi-notification-wake && git checkout -b verify-1681-r1 FETCH_HEAD, head ce123f38e; origin/main is an ancestor, and main is still at protocol 146 so the 147 bump does not collide).

Fail-before / pass-after (checked out the origin/main versions of the 6 non-test source files, ran the new tests, then restored):

  • packages/agent-runtime vitest run src/pi/delta-translation.test.ts -t "custom message": 3 failed on main. First assertion: AssertionError: expected [ Array(1) ] to deeply equal [ { type: 'item/completed', …(4) } ], received a provider/unhandled whose rawEvent.params.message.message.role is "custom". Third: expected [ Array(1) ] to deeply equal []. All 3 pass on the PR tree.
  • packages/thread-view vitest run test/timeline-cli-rendering.snapshots.test.ts -t "provider-injected": fails on main with expected [ { initiator: 'user', …(2) } ] to deeply equal [ { initiator: 'user', …(2) }, …(1) ]; passes on the PR tree.
  • apps/server vitest run test/services/threads/timeline-provider-input.test.ts: fails on main (- "user:<process_event …>" missing from the page); passes on the PR tree. Also re-checked the guard: patching parseProviderUserMessage to kind: "message" makes it fail with the first turn dropped (expected [ …(2) ] to deeply equal [ 'user:Reply only with ok.', …(3) ]).

Turbo, from the committed tree:

  • turbo run typecheck for provider-bridge-protocol, agent-runtime, thread-view, host-daemon-contract, host-daemon, server, cli, app, provider-acp, provider-codex, provider-claude-code: Tasks: 15 successful, 15 total.
  • turbo run test --force for provider-bridge-protocol (10 files), agent-runtime (31), thread-view (21), host-daemon-contract (3), provider-acp (14), provider-codex (16), provider-claude-code (18): Tasks: 11 successful, 11 total.
  • turbo run test --filter=@bb/server --force: 195/196 files; the one failure is internal-skill-trees.test.ts (file mode 436 vs 420, local umask 0002; passes in CI).

Repro on the fixed branch: own dev instance with PI_CODING_AGENT_DIR pointing at a trust-listed copy of the Pi agent dir and the report's 30-line stand-in extension, real Pi session (thread spawn --provider pi --permission-mode full --prompt "Reply only with ok.", thread thr_byejnv6paz). Events: 16 turn/started t2, 17 item/completed userMessage <process_event …>, reasoning, agentMessage "ok", 27 turn/completed status=completed, thread status=idle, zero provider/unhandled (main produced two, for sdk/message_start and sdk/message_end). The app shows the first turn intact, then "Worked for 3s" which expands to a "System Message" row with the process event, then ok. bb thread log --format verbose shows the nested User row with the process event and steer.

CI at verification time: all ubuntu checks green (Checks, Package Smoke, Tests app-1/2/3, integration, packages, server); macOS Package Smoke pending.

Residual risks / notes for the reviewer:

  • In the default views the provider input is hidden until expanded: the app folds it into the collapsed "Worked for" group (existing policy for system-initiated steers) and bb thread log in its default minimal format prints an empty ── Worked for (3s) header with no input row; only --format verbose shows it. The PR body's "bb thread log shows the process event as a User row" holds for verbose only. Making provider input ungrouped without turning it into a pagination anchor needs a product decision (a distinct initiator, or teaching the DB anchor query about userMessage items).
  • Idle attention: context notes (no open turn) are dropped, not persisted; image blocks in custom messages are ignored.
  • Linked PR Fix Pi custom message turns #1682 is mergeable=CONFLICTING and edits event-translation.ts, which Narrow-grammar provider bridge protocol: bridges emit semantic deltas, the runtime assembles the timeline (v2) #1834 deleted; it cannot land on main.

AGENT GENERATED: by Claude Opus 5

Stack

Layer 1/2 of GitHub stack #2217 (gh stack), lands first. Base main, HOST_DAEMON_PROTOCOL_VERSION 151. Rebased onto main at 75d6fc4d4 (protocol 150): the only conflicts were protocol.ts and contract.test.ts; the rebase also regenerated the bridge grammar snapshot for the new input.provider delta kind. Re-verified on the new base: the three new test files fail with the six non-test source files checked out from origin/main (expected [ Array(1) ] to deeply equal [ { type: 'item/completed', …(4) } ], expected [ 'user:Reply only with ok.', …(2) ] to deeply equal [ 'user:Reply only with ok.', …(3) ]) and pass on this head; turbo run typecheck test for host-daemon-contract, host-daemon, agent-runtime, provider-bridge-protocol, thread-view and server is green except the known local-only umask internal-skill-trees assertion. #2142 (layer 2/2, protocol 152) is stacked on this branch.

AGENT GENERATED: by Claude Opus 5

Independent verification (guards)

Re-verified head bdf47388f (rebased onto origin/main 27d1017fe, @get-bb/plugin-sdk 0.4.12) against the previously verified head 3158939df on a fresh checkout (verify-2154-g).

  • git range-diff 75d6fc4d4..3158939df origin/main..bdf47388f: the single commit differs only in packages/domain/src/plugin-sdk-version.ts (0.4.11 -> 0.4.12) and packages/plugin-sdk/package.json (0.4.11 -> 0.4.12). Every other hunk is identical. origin/main and npm (npm view @get-bb/plugin-sdk version) are both still at 0.4.11 and main is still at protocol 150, so neither bump collides.
  • Guards on this head: node packages/plugin-sdk/scripts/check-npm-version-guard.mjs -> PASS — @get-bb/plugin-sdk@0.4.12 is not on npm yet; node scripts/check-provider-literal-ratchet.mjs -> OK: 148 references across 40 core files.
  • Fail-before / pass-after, re-run once on this head (checked out the origin/main copies of the 9 existing non-test source files and deleted the new delta-translation.ts, rebuilt, ran, restored; git status --porcelain empty afterwards):
    • agent-runtime delta-translation.test.ts: cannot load on main (Cannot find module './delta-translation.js'); passes on the PR tree.
    • thread-view timeline-cli-rendering.snapshots.test.ts: AssertionError: expected [ { initiator: 'user', …(2) } ] to deeply equal [ { initiator: 'user', …(2) }, …(1) ] on main; 50/50 pass on the PR tree.
    • server timeline-provider-input.test.ts: AssertionError: expected [ 'user:Reply only with ok.', …(2) ] to deeply equal [ 'user:Reply only with ok.', …(3) ] on main; passes on the PR tree.
    • host-daemon-contract contract.test.ts: expected 150 to be 151 on main; 52/52 pass on the PR tree.
  • turbo run typecheck test --force for agent-runtime, thread-view, host-daemon-contract, provider-bridge-protocol, domain, @get-bb/plugin-sdk: Tasks: 17 successful, 17 total (442 + 391 + 52 + 218 + 150 + 127 tests). turbo run typecheck --filter=@bb/server: Tasks: 4 successful, 4 total.
  • CI for bdf47388f (run 32508210319): Checks, Package Smoke (ubuntu + macOS), Tests app-1/2/3, integration, packages, server all pass; mergeable=MERGEABLE, mergeStateStatus=CLEAN against main.
  • The live Pi repro was not re-run in this pass: the diff against the previously verified head (where it was run with a real Pi extension turn) is the two version strings above, which do not reach the runtime path.

Rebase (2026-08-21, second)

Rebased onto main at d41d1abee. Two collisions, both from main moving under the PR:

  • main took protocol 151 (Prevent competing turns and false Codex session rebuilds #2242, the auto/steer turn-target re-resolution), so this PR's change is renumbered 151 -> 152. Its comment block now sits above main's 151 block, and the lockstep assertion in contract.test.ts moves to toBe(152). No other file hardcodes the constant; every other consumer reads it symbolically.
  • main moved the SDK to 0.4.13, so plugin-sdk-version.ts and plugin-sdk/package.json resolve to main's values and drop out of this PR's diff.

Re-verified on the new base: check-npm-version-guard.mjs -> PASS - @get-bb/plugin-sdk@0.4.13 is not on npm yet. turbo run typecheck for server, agent-runtime, host-daemon-contract, provider-bridge-protocol, thread-view: Tasks: 8 successful, 8 total. turbo run test for the same set: host-daemon-contract 3/3 files, provider-bridge-protocol 16/16, thread-view 23/23, agent-runtime 31/31, server 200 passed / 1 skipped with the single known local-only failure internal-skill-trees (mode 420 vs 436, i.e. 0644 vs 0664 under this machine's umask 0002); it passes in CI and this PR does not touch skill trees.

#2142 (layer 2/2) is renumbered to protocol 153 and rebased on this head.

AGENT GENERATED: by Claude Opus 5

@SawyerHood
SawyerHood marked this pull request as ready for review August 21, 2026 06:02
@SawyerHood
SawyerHood force-pushed the bb/fix-1681-pi-notification-wake branch from ce123f3 to 3158939 Compare August 21, 2026 16:51
SawyerHood added a commit that referenced this pull request Aug 21, 2026
A forked thread came up with an empty timeline. The provider cloned the
source session, so the model held the whole conversation, but nothing
in the fork path wrote that conversation into the fork's events, and
the timeline is a projection of a thread's own events.

The same path ignored `sourceSeqEnd` for the model: it only looked up
which provider session was current at that sequence and never mapped
it to the `providerCheckpointId` the bridge records on `turn/completed`,
so pi, claude-code, and codex always cloned the tip regardless of the
requested anchor.

The server now resolves a fork point: the completed root turn that
contains `sourceSeqEnd` (or the last one before it), its recorded
checkpoint, and the sequence of its `turn/completed`. `thread.start.fork`
carries `sourceProviderCheckpointId`, the runtime forwards it on
`thread/fork`, and the bridge branches the session there. A visible fork
then inherits the source's conversation rows through that same sequence
before its own thread-start rows, so its timeline and its model describe
the same history. Hidden forks (side chats) still clone the session but
keep their own timeline empty. Anchors inside a running turn, before the
first turn, or earlier than the tip on a tip-only provider fail with
`fork_source_session_unavailable` instead of silently forking the tip.

A tip fork of a source that is mid-turn cloned a session that already
held the running turn's prompt while the inherited timeline stopped at
the last completed turn. Providers that can branch at a checkpoint now
clone through that completed turn so model context and timeline match;
tip-only providers keep the tip clone.

Bumps HOST_DAEMON_PROTOCOL_VERSION to 152 (stacked on #2154, which
takes 151).

Fixes #1500

Co-Authored-By: Claude <noreply@anthropic.com>
@SawyerHood
SawyerHood marked this pull request as draft August 21, 2026 17:05
@SawyerHood
SawyerHood force-pushed the bb/fix-1681-pi-notification-wake branch from 3158939 to bdf4738 Compare August 21, 2026 17:26
SawyerHood added a commit that referenced this pull request Aug 21, 2026
A forked thread came up with an empty timeline. The provider cloned the
source session, so the model held the whole conversation, but nothing
in the fork path wrote that conversation into the fork's events, and
the timeline is a projection of a thread's own events.

The same path ignored `sourceSeqEnd` for the model: it only looked up
which provider session was current at that sequence and never mapped
it to the `providerCheckpointId` the bridge records on `turn/completed`,
so pi, claude-code, and codex always cloned the tip regardless of the
requested anchor.

The server now resolves a fork point: the completed root turn that
contains `sourceSeqEnd` (or the last one before it), its recorded
checkpoint, and the sequence of its `turn/completed`. `thread.start.fork`
carries `sourceProviderCheckpointId`, the runtime forwards it on
`thread/fork`, and the bridge branches the session there. A visible fork
then inherits the source's conversation rows through that same sequence
before its own thread-start rows, so its timeline and its model describe
the same history. Hidden forks (side chats) still clone the session but
keep their own timeline empty. Anchors inside a running turn, before the
first turn, or earlier than the tip on a tip-only provider fail with
`fork_source_session_unavailable` instead of silently forking the tip.

A tip fork of a source that is mid-turn cloned a session that already
held the running turn's prompt while the inherited timeline stopped at
the last completed turn. Providers that can branch at a checkpoint now
clone through that completed turn so model context and timeline match;
tip-only providers keep the tip clone.

Bumps HOST_DAEMON_PROTOCOL_VERSION to 152 (stacked on #2154, which
takes 151).

Fixes #1500

Co-Authored-By: Claude <noreply@anthropic.com>
@SawyerHood
SawyerHood marked this pull request as ready for review August 21, 2026 17:48
A Pi extension can inject a custom message and trigger a turn on its
own (`pi.sendMessage(..., { triggerTurn: true })`, which is how
pi-processes wakes a thread when a background command finishes). bb
translated the message's `message_start`/`message_end` boundaries as
`provider/unhandled` and never recorded the message itself, so the
extension-triggered turn showed an assistant answer with no input in
front of it, plus "Unhandled Pi event" rows where unhandled events are
visible.

Add an `input.provider` delta to the bridge grammar for input the
provider injected without a bb client request. The Pi translator emits
it for a displayed `role: "custom"` `message_start`; the assembler
records it as the `userMessage` item of the open turn and drops it when
no turn is open (idle context notes Pi appends without running the
agent). Custom boundaries are noise for the visibility fallback, so
`message_end` and hidden messages no longer surface as unhandled.

thread-view projects the item as a system-initiated accepted steer of
its turn, so the app, `bb thread log`, and the turn summary show what
the model was answering. It is a steer rather than a message on
purpose: the server pages the timeline on `message` rows backed by
stored `client/turn/requested` events, and a `message` row without one
made the latest page silently drop every earlier turn.

Bump HOST_DAEMON_PROTOCOL_VERSION: the daemon now emits a userMessage
item it never sent before.

Co-Authored-By: Claude <noreply@anthropic.com>
@SawyerHood
SawyerHood force-pushed the bb/fix-1681-pi-notification-wake branch from bdf4738 to 9e632c3 Compare August 21, 2026 22:06
SawyerHood added a commit that referenced this pull request Aug 21, 2026
A forked thread came up with an empty timeline. The provider cloned the
source session, so the model held the whole conversation, but nothing
in the fork path wrote that conversation into the fork's events, and
the timeline is a projection of a thread's own events.

The same path ignored `sourceSeqEnd` for the model: it only looked up
which provider session was current at that sequence and never mapped
it to the `providerCheckpointId` the bridge records on `turn/completed`,
so pi, claude-code, and codex always cloned the tip regardless of the
requested anchor.

The server now resolves a fork point: the completed root turn that
contains `sourceSeqEnd` (or the last one before it), its recorded
checkpoint, and the sequence of its `turn/completed`. `thread.start.fork`
carries `sourceProviderCheckpointId`, the runtime forwards it on
`thread/fork`, and the bridge branches the session there. A visible fork
then inherits the source's conversation rows through that same sequence
before its own thread-start rows, so its timeline and its model describe
the same history. Hidden forks (side chats) still clone the session but
keep their own timeline empty. Anchors inside a running turn, before the
first turn, or earlier than the tip on a tip-only provider fail with
`fork_source_session_unavailable` instead of silently forking the tip.

A tip fork of a source that is mid-turn cloned a session that already
held the running turn's prompt while the inherited timeline stopped at
the last completed turn. Providers that can branch at a checkpoint now
clone through that completed turn so model context and timeline match;
tip-only providers keep the tip clone.

Bumps HOST_DAEMON_PROTOCOL_VERSION to 152 (stacked on #2154, which
takes 151).

Fixes #1500

Co-Authored-By: Claude <noreply@anthropic.com>
@SawyerHood
SawyerHood merged commit b3dd891 into main Aug 21, 2026
13 checks passed
@SawyerHood
SawyerHood deleted the bb/fix-1681-pi-notification-wake branch August 21, 2026 22:15
SawyerHood added a commit that referenced this pull request Aug 21, 2026
## What was wrong

A forked thread came up with an empty timeline (#1500, report:
https://get-bb.github.io/reports/issues/1500.html). The provider cloned
the source session, so the model held the whole conversation, but
nothing in the fork path (`thread-fork.ts` -> `thread-create.ts`
`resolveForkDescriptor` -> `thread.start{fork}`) ever wrote that
conversation into the fork's events, and the timeline is a projection of
a thread's own events. The same path also ignored `sourceSeqEnd` for the
model: it only looked up which provider session was current at that
sequence (`getProviderThreadIdAtOrBeforeSequence`) and never mapped it
to the `providerCheckpointId` recorded on that turn's `turn/completed`.
`thread.start.fork` had no checkpoint field, so pi, claude-code, and
codex always cloned the session tip regardless of the requested anchor,
and the app's per-message Fork button and `bb thread fork
--source-seq-end` promised a branch point they did not deliver.

## What changed

- `apps/server/src/services/threads/thread-fork-history.ts` (new):
resolves a fork point. With `sourceSeqEnd` the anchor is the completed
root turn that contains the sequence (or the last one before it, so a
user-message row anchors before its own turn, like editing it). The
point carries the session to clone, that turn's checkpoint
(`resolveTurnProviderCheckpointId`, shared with edit-message including
the legacy codex turn-id fallback), and the sequence of its
`turn/completed`. It rejects with 400 `fork_source_session_unavailable`
when the anchor is before the first turn, inside a turn that has not
completed, has no checkpoint, or is earlier than the latest turn on a
provider without `supportsSessionRewind` (tip-only, e.g. ACP) instead of
silently forking the tip.
- Tip forks (no `sourceSeqEnd`) inherit every completed root turn. When
the source is idle the fork clones the session tip. When the source is
mid-turn, its session tip already holds the running turn's prompt and
partial output while the inherited timeline stops at the last completed
turn, so a checkpoint-capable provider now clones through that last
completed turn's checkpoint instead; tip-only providers keep the tip
clone. This addresses the reviewer's point that model context and
timeline diverged in that window.
- `thread-create.ts`: a visible fork copies the source's conversation
rows (`client/turn/requested` with its acceptance, `turn/started`,
`turn/input/accepted`, `item/completed`,
`item/backgroundTask/completed`, `turn/completed`, `thread/compacted`,
`system/manager/user_message`) through that sequence into the fork
before its own thread-start rows, so inherited history takes the lowest
sequences. Only turns that completed inside the window come along.
Copied rows keep their payload and `created_at`, take fresh ids and
sequences, index search segments, and carry a null `provider_thread_id`
column so the fork's live session stays the one its own
`thread/identity` names. Hidden forks (side chats, plugin workers
rendered by their owner next to the source) still clone the session but
keep their own timeline empty; this also keeps the side-chat plugin's
empty-fork sweep from seeing inherited user messages as user work.
- Side-chat behavior change (plugin code untouched): the plugin passes
the anchored message's `sourceSeqEnd` to `threads.fork`. On main that
anchor only chose a provider session id, so pi/claude-code/codex side
chats always cloned the tip. Now a side chat anchored on an earlier
completed turn gets a checkpoint fork whose model context ends at that
turn, which is what the plugin's own comments describe. An anchor inside
a running turn, or an earlier turn on a tip-only (ACP) provider, returns
`fork_source_session_unavailable`, which the plugin already handles by
falling back to a tip fork (`plugins/side-chat/server.ts`, covered by
its existing tests).
- `packages/db/src/data/events.ts`:
`copyStoredThreadEventsInTransaction`, `findLastRootStoredTurnStarted`,
`findLastCompletedRootStoredTurn`; removes the now-unused
`getStoredProviderThreadIdAtOrBeforeSequence`. The row copy reads the
selected source rows into memory once (one bounded typed query per
inherited type) because each copy is re-parsed to index search segments;
the in-memory filter only drops the few rows of turns or queued requests
still open at the window's end. Large source threads pay that memory and
duplicate storage per fork.
- Wire: `thread.start.fork.sourceProviderCheckpointId?: string` in
`@bb/host-daemon-contract` (absent = tip, matching the bridge protocol's
`thread/fork`), forwarded by `packages/agent-runtime/src/runtime.ts` to
the adapter's `thread/fork`. **`HOST_DAEMON_PROTOCOL_VERSION` 152 ->
153** (146 -> 147, then 151 -> 152, before two rebases onto a moving
main). An older daemon would strip the field and clone the tip.
- `thread-edit-message.ts`: uses the shared
`resolveTurnProviderCheckpointId` instead of its inline codex fallback
(no behavior change).
- CI guard (provider-literal ratchet): `resolveTurnProviderCheckpointId`
stays in `thread-edit-message.ts` as an export and
`thread-fork-history.ts` imports it from there, instead of moving it
into `thread-events.ts`, which would have given that file a new
`"codex"` literal; `scripts/provider-literal-baseline.json` is
regenerated for the resulting decrease (`thread-edit-message.ts` 2 -> 1,
total 148 -> 147).
- Docs/CLI: `--source-seq-end` help text in `bb thread fork` / `bb
thread spawn`, `bb-guide-threads.md`, the bb-cli SKILL,
`docs/provider-bridge-protocol.md`, and the
`forkThreadRequestSchema.sourceSeqEnd` doc now describe the
turn-anchored semantics.

Deviation from the issue: the issue also floats an `events.append` /
"adopt session" SDK API (#1028). Not done here; copying at fork time
covers the fork case with no new plugin surface. The choice to skip
history for hidden forks is a server policy decision; an explicit
request knob can be added later if a hidden fork ever needs inherited
history.

Revision after review: `copyForkSourceHistory` was declared `: number`
with a bare `return;`, which failed `@bb/server` typecheck and the CI
Checks job; it returns `void` now (its caller never read the count). The
mid-turn tip fork behavior above is new in this revision. Rebased onto
current main (resolved against #2140's dead-code removal in `@bb/db` and
`thread-create.ts`).

## How you verified

New tests in `apps/server/test/public/public-thread-fork.test.ts` ("fork
branch point and inherited history": checkpoint + inherited conversation
for an anchor on an assistant message, a user-message anchor branching
before its turn, idle tip fork inheriting every completed turn, mid-turn
tip fork branching at the last completed turn's checkpoint with nothing
of the running turn, a mid-turn tip fork on a tip-only provider cloning
the tip, hidden fork inheriting nothing, rejections for
running-turn/pre-first-turn anchors, tip-only provider accepting only
its latest turn) plus checkpoint assertions in
`thread-create-seed-without-run.test.ts` and
`public-thread-fork.test.ts`'s existing fork-point test.

Fail before (the 14 non-test source files checked out from
`origin/main`, new module removed, tests from this branch): 10 failed,
e.g.

```
AssertionError: expected { Object (sourceProviderThreadId) } to deeply equal { …(2) }
AssertionError: expected [] to deeply equal [ 'Reply only with ok.', 'ok', …(2) ]
AssertionError: expected 201 to be 400 // Object.is equality
```

Pass after, on the rebased head: `pnpm exec turbo run typecheck` for
server, agent-runtime, host-daemon-contract, db, host-daemon, sdk, cli,
server-contract, templates green (12/12 tasks). `pnpm exec turbo run
lint` green. `pnpm exec turbo run test --filter=@bb/server`: 1829
passed, 2 failed, both unrelated (the known local umask-only
`internal-skill-trees` 0644-vs-0664 assertion, and a 5s timeout in
`plugin-update.test.ts` under parallel load that passes alone, 27/27).
`@bb/host-daemon` 551, `@bb/agent-runtime` 418, `@bb/cli` 453,
`@bb/host-daemon-contract` 52, `@bb/server-contract` 58, `@bb/db` 405,
`@bb/templates` 41, `bb-plugin-side-chat` 29, all passed.

Manual repro on an own dev instance with the pi provider on the rebased
head (two cheap turns "ok" / "second"; `turn/completed` at seq 15
recorded checkpoint `32261d5c`):

- `bb thread fork <src> --workspace reuse` -> `bb thread log` shows all
four inherited messages.
- `bb thread fork <src> --workspace reuse --source-seq-end 13` (turn 1's
assistant message) -> log shows "Reply only with ok." / "ok" only; the
fork's pi session file has `parentSession=<src>` and only turn 1's user
and assistant entries (checkpoint inclusive).
- `--source-seq-end 3` -> `HTTP 400: Cannot fork at sequence 3: no turn
has started at or before it`.
- Mid-turn: started a third turn that runs `sleep 40`, forked at the tip
while `turn/started`=3 and `turn/completed`=2. The fork's log shows the
four completed messages; its pi session file has `parentSession=<src>`
and only turns 1-2, while the source's session already held turn 3's
prompt and its bash tool call.

Fixes #1500

> AGENT GENERATED: by Claude Opus 5



## Independent verification

Verified round 2 at head `96289f260` (origin/main `f6fb434ab` is an
ancestor; no rebase needed) in a separate worktree with its own dev
instance (ports 13662/21662/29662).

Commands:

- `git checkout origin/main -- <11 non-test source files>` + remove
`thread-fork-history.ts`, then `pnpm exec vitest run
test/public/public-thread-fork.test.ts
test/threads/thread-create-seed-without-run.test.ts` from `apps/server`:
**10 failed / 30 passed**. Failing assertions: `expected { Object
(sourceProviderThreadId) } to deeply equal { …(2) }`, `expected [] to
deeply equal [ 'Reply only with ok.', 'ok', …(2) ]`, `expected 201 to be
400`, `expected undefined to be 'checkpoint-after-turn-1'`.
- `git checkout HEAD -- <same files>`, same command: **40 passed**.
- `pnpm exec turbo run typecheck` for `@bb/server`, `agent-runtime`,
`host-daemon-contract`, `db`, `host-daemon`, `sdk`, `cli`,
`server-contract`, `templates`, `app`: `Tasks: 13 successful, 13 total`.
- `pnpm exec turbo run test --force` for `@bb/server`, `db`,
`host-daemon-contract`, `agent-runtime`, `server-contract`, `cli`,
`templates`, `host-daemon`, `bb-plugin-side-chat`: 14/15 tasks green;
`@bb/server` 1830 passed / 1 failed, the failure being the known local
umask-only `internal-skill-trees` mode 0644-vs-0664 assertion
(unrelated, passes in CI).
- CI (`gh pr checks 2142`): all jobs pass (Checks, Package Smoke x2,
Tests app-1/2/3, integration, packages, server).

Live repro on the fixed branch (pi provider, source thread with two
turns "ok"/"second", turn 1 `turn/completed` at seq 15 with checkpoint
`9f0ee844`):

- `bb thread fork <src> --workspace reuse`: timeline has all four
conversation rows; fork events are the 10 inherited rows (null
`provider_thread_id` column, payload keeps the source session id)
followed by the fork's own `client/turn/requested`,
`client/thread/start`, `thread/identity`, provisioning.
- `--source-seq-end 13` (turn 1's assistant message): timeline shows
"Reply only with ok." / "ok" only; the fork's pi session file has
`parentSession=<src>` and only turn 1's two entries.
- `--source-seq-end 3`: `HTTP 400: Cannot fork at sequence 3: no turn
has started at or before it`.
- Mid-turn (turn 3 running `sleep 45`): tip fork inherits turns 1-2 only
and its pi session holds turns 1-2 while the source's already holds turn
3's prompt; `sourceSeqEnd: 28` (inside the running turn) returns 400
`fork_source_session_unavailable`.
- After turn 3 (with a `commandExecution` tool call) completed, a tip
fork renders the inherited tool-call turn as a completed "Worked for"
group in both `/timeline` and `bb thread log`; no crash on
`item/completed` rows without `item/started`.
- Browser (headless Chrome): the seq-13 fork page renders the inherited
user/assistant rows plus the "Forked from" chip.

Residual risks: wire change needs enrolled daemons on protocol 147
(older daemons would strip the checkpoint and clone the tip); checkpoint
forks on claude-code/codex reuse the rewind path's checkpoint ids but
were not live-tested here (pi was); side chats anchored on an earlier
completed turn now get a checkpoint fork rather than a tip clone;
`findLastCompletedRootStoredTurn` accepts an `atOrBeforeSequence` bound
no caller passes (minor dead parameter). Hidden forks inherit no history
by server policy.

> AGENT GENERATED: by Claude Opus 5

## Stack

Layer 2/2 of GitHub stack #2217 (`gh stack`), depends on #2154 (layer
1/2, protocol 151). Base is `bb/fix-1681-pi-notification-wake`;
`HOST_DAEMON_PROTOCOL_VERSION` 152. After #2154 merges, GitHub retargets
this PR to `main`. Rebased onto #2154's head (itself on main at
`75d6fc4d4`): conflicts were `protocol.ts`, `contract.test.ts`, and
`docs/provider-bridge-protocol.md` (kept main's rewritten open-work note
and this PR's fork note side by side); the two review-round commits are
squashed into one. Re-verified on the new base: the fork tests fail with
the 14 non-test source files checked out from the base (`9 failed`, e.g.
`expected { Object (sourceProviderThreadId) } to deeply equal { …(2) }`,
`expected [] to deeply equal [ 'Reply only with ok.', 'ok', …(2) ]`,
`expected 201 to be 400`) and pass on this head; `turbo run typecheck
test` for host-daemon-contract, host-daemon, agent-runtime, server, db,
server-contract, cli and templates is green except the known local-only
umask `internal-skill-trees` assertion.

> AGENT GENERATED: by Claude Opus 5



## Independent verification (guards)

Re-verified head `d811c206c` (protocol 152, base
`bb/fix-1681-pi-notification-wake` at `bdf47388f`, itself on
`origin/main` `27d1017fe`) after the CI-guard amendments, in a separate
worktree.

- Guard fix is mechanical: `git range-diff e522476~1..e522476
origin/bb/fix-1681-pi-notification-wake..HEAD` shows only (a)
`resolveTurnProviderCheckpointId` + `CODEX_NATIVE_TURN_ID_PATTERN` moved
verbatim from `thread-events.ts` to an export in
`thread-edit-message.ts`, (b) `thread-fork-history.ts` importing it from
`./thread-edit-message.js`, and (c)
`scripts/provider-literal-baseline.json` `thread-edit-message.ts` 2 ->
1, total 148 -> 147. The bottom layer's range-diff shows only the
`@get-bb/plugin-sdk` 0.4.11 -> 0.4.12 bump
(`packages/plugin-sdk/package.json`,
`packages/domain/src/plugin-sdk-version.ts`). Everything else in `git
diff e522476 HEAD --stat` is the four unrelated main commits picked up
by the rebase.
- No import cycle from the move: `thread-create.ts ->
thread-fork-history.ts -> thread-edit-message.ts`, and none of
`thread-edit-message.ts`'s local imports (`thread-send`,
`thread-turn-dispatch`, `thread-lifecycle`, `thread-commands`,
`thread-runtime-config`, `thread-data`, `thread-events`) import
`thread-create` or `thread-fork*`.
- `node scripts/check-provider-literal-ratchet.mjs`: `Provider-literal
ratchet OK: 147 references across 40 core files.`
- Fail-before: `git checkout origin/bb/fix-1681-pi-notification-wake --
<17 non-test source files>` + `thread-fork-history.ts` removed, then
`vitest run test/public/public-thread-fork.test.ts
test/threads/thread-create-seed-without-run.test.ts` in `@bb/server`:
**10 failed / 30 passed** (`expected [] to deeply equal [ 'Reply only
with ok.', 'ok', …(2) ]`, `expected { Object (sourceProviderThreadId) }
to deeply equal { …(2) }`, `expected 201 to be 400`, `expected undefined
to be 'checkpoint-after-turn-1'`).
- Pass-after: `git checkout HEAD -- .`, same command: **40 passed**.
- `pnpm exec turbo run typecheck --filter=@bb/server
--filter=@bb/host-daemon-contract`: `Tasks: 5 successful, 5 total`;
`@bb/host-daemon-contract` `contract.test.ts`: 38 passed.
- CI: `gh pr checks 2142` all pass on run `32508211062` (head_sha
`d811c206c`): Checks, Package Smoke x2, Tests app-1/2/3, integration,
packages, server. `mergeable: MERGEABLE`, `mergeStateStatus: CLEAN`.
#2154 (bottom layer) is also fully green and CLEAN on `main`.
- Live repro was not re-run for this guard-only pass; the earlier
round's pi-provider repro on the same logic stands (the moved helper's
body is byte-identical).


## Rebase (2026-08-21, second)

Rebased onto #2154's new head `9e632c399`. `main` took protocol **151**
(#2242), which pushed #2154 to **152**, so this layer is renumbered
**152 -> 153**: its comment block sits above #2154's 152 block and the
lockstep assertion in `contract.test.ts` moves to `toBe(153)`.
`protocol.ts` was the only conflict; the SDK version files no longer
appear in the stack's diff, because both layers now adopt main's
unpublished `0.4.13`.

Re-verified on the new base: `node
scripts/check-provider-literal-ratchet.mjs --base <#2154 head>` -> `OK:
147 references across 40 core files` (the helper stays in
`thread-edit-message.ts`). `turbo run typecheck` for cli, server,
agent-runtime, db, host-daemon-contract, server-contract, templates:
`Tasks: 10 successful, 10 total`. `turbo run test` for the same set:
server-contract 7/7 files, host-daemon-contract 3/3, db 28/28,
agent-runtime 31/31, cli 48/48, templates 6/6, server 200 passed / 1
skipped with only the known local-only umask failure
`internal-skill-trees` (0644 vs 0664 under umask 0002), which passes in
CI.

Stack #2217 is unchanged: base is still
`bb/fix-1681-pi-notification-wake`, and GitHub retargets this PR to
`main` when #2154 merges.

> 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.

Fix Pi process notification wake projection

1 participant