pi-claude-agent-sdk 0.8.6 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,14 +1,13 @@
1
- import { calculateCost, type AssistantMessage, type AssistantMessageEventStream, type Context, type ImageContent, type Model, type SimpleStreamOptions, type TextContent, type Tool, type UserMessage } from "@earendil-works/pi-ai";
2
- import * as piAi from "@earendil-works/pi-ai";
1
+ import { createAssistantMessageEventStream, type AssistantMessage, type AssistantMessageEventStream, type Context, type ImageContent, type Model, type SimpleStreamOptions, type TextContent, type Tool, type UserMessage } from "@earendil-works/pi-ai";
3
2
  import { getModels } from "@earendil-works/pi-ai/compat";
4
3
  import { type ExtensionAPI, type ExtensionContext, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
5
4
  import { query, type EffortLevel, type SDKMessage, type SettingSource } from "@anthropic-ai/claude-agent-sdk";
6
5
  import type { Base64ImageSource, ContentBlockParam } from "@anthropic-ai/sdk/resources";
7
6
  import { createSession, deleteSession, openSession, repairToolPairing } from "cc-session-io";
8
7
  import { appendFileSync, mkdirSync, realpathSync, statSync } from "fs";
9
- import { homedir } from "os";
10
8
  import { dirname, join } from "path";
11
9
  import { PROVIDER_ID, messageContentToText, convertPiMessages } from "./convert.js";
10
+ import { DEBUG_LOG_PATH, DIAG_LOG_PATH } from "./log-paths.js";
12
11
  import { adaptiveThinkingAlwaysOn, applyLongContext, buildModels, claudeCodeModelId, thinkingBoundToPrefix, type LongContextSettings } from "./models.js";
13
12
  import { MCP_SERVER_NAME, MCP_TOOL_PREFIX } from "./skills.js";
14
13
  import { verifyWrittenSession as _verifyWrittenSession } from "./session-verify.js";
@@ -17,28 +16,22 @@ import { QueryContext, ctx } from "./query-state.js";
17
16
  import { makePromptStream, userMessage, type PromptStream } from "./prompt-stream.js";
18
17
  import { claudeCodeSettings, loadConfig, markStartupNoticeShown, type Config } from "./config.js";
19
18
  import {
20
- getSharedPromptCaptures,
21
19
  projectPromptCapture,
22
- PromptCaptures,
20
+ sharedPromptCaptures,
21
+ type PromptCapture,
23
22
  } from "./prompt-capture.js";
24
23
  import { collectCarriedAttachments, placeCarriedAttachments, type CarriedAttachment } from "./attachments.js";
25
24
  import { createToolServer } from "./mcp-server.js";
26
25
  import { CC_CHILD_ENV, resolveClaudeChildEnv, type AnthropicAuthRegistry } from "./child-env.js";
27
26
  import { resolveClaudeCodeExecutable } from "./claude-executable.js";
28
-
29
- // Compat (#2): use factory if available (pi-ai ≥0.66), else fall back to constructor (gsd-pi etc.)
30
- const _piAi = piAi as any;
31
- const newAssistantMessageEventStream: () => AssistantMessageEventStream =
32
- typeof _piAi.createAssistantMessageEventStream === "function"
33
- ? _piAi.createAssistantMessageEventStream
34
- : () => new _piAi.AssistantMessageEventStream();
27
+ import { nonSystemMessages, toBridgeContext } from "./transcript.js";
28
+ import { updateUsage, type SdkUsage } from "./usage.js";
35
29
 
36
30
  // --- Debug logging ---
37
- // CLAUDE_BRIDGE_DEBUG=1 enables debug logging to ~/.pi/agent/claude-bridge.log
31
+ // CLAUDE_BRIDGE_DEBUG=1 enables debug logging to the bridge log in pi's agent
32
+ // dir (log-paths.ts), not a fixed ~/.pi/agent.
38
33
 
39
34
  const DEBUG = process.env.CLAUDE_BRIDGE_DEBUG === "1";
40
- const DEBUG_LOG_PATH = process.env.CLAUDE_BRIDGE_DEBUG_PATH || join(homedir(), ".pi", "agent", "claude-bridge.log");
41
- const DIAG_LOG_PATH = join(homedir(), ".pi", "agent", "claude-bridge-diag.log");
42
35
 
43
36
  // CLAUDE_BRIDGE_RECORD_STREAM=<path> appends every SDK message consumeQuery sees,
44
37
  // one JSON object per line. Used by tests/lib/record-sdk-streams.mjs to capture
@@ -46,7 +39,6 @@ const DIAG_LOG_PATH = join(homedir(), ".pi", "agent", "claude-bridge-diag.log");
46
39
  // emitted rather than ones we imagined.
47
40
  const RECORD_STREAM_PATH = process.env.CLAUDE_BRIDGE_RECORD_STREAM;
48
41
 
49
-
50
42
  // Pi owns context files on the provider path, so Claude Code must not load its
51
43
  // own on top: otherwise a project CLAUDE.md arrives twice, and the user's
52
44
  // ~/.claude/CLAUDE.md — a persona written for a harness that is not the one
@@ -60,11 +52,10 @@ const RECORD_STREAM_PATH = process.env.CLAUDE_BRIDGE_RECORD_STREAM;
60
52
  // while rules need their own. Managed/policy memory is not excludable by design.
61
53
  const CLAUDE_MD_EXCLUDES = ["**/CLAUDE.md", "**/.claude/rules/**"];
62
54
 
63
- // Ensure log directories exist when debug is enabled
55
+ // Ensure the debug log directory exists when debug is enabled
64
56
  if (DEBUG) {
65
57
  try {
66
58
  mkdirSync(dirname(DEBUG_LOG_PATH), { recursive: true });
67
- mkdirSync(dirname(DIAG_LOG_PATH), { recursive: true });
68
59
  } catch {
69
60
  // If directory creation fails, debug functions will throw on first use
70
61
  }
@@ -88,7 +79,7 @@ function debug(...args: unknown[]) {
88
79
  // Per-query CLI debug capture. When CLAUDE_BRIDGE_DEBUG=1, ask the Claude Code
89
80
  // CLI subprocess to write its own debug log to a file we choose, and also
90
81
  // forward its stderr into our debug stream. Drops straight into the real SDK's
91
- // Options — see @anthropic-ai/claude-agent-sdk sdk.d.ts:2091 (debug, debugFile,
82
+ // Options — see @anthropic-ai/claude-agent-sdk sdk.d.ts:1245 (debug, debugFile,
92
83
  // stderr). Without this, CC's internal view of the world is invisible to us
93
84
  // and "No conversation found" / empty-error reports are unactionable.
94
85
  let nextCliDebugSeq = 1;
@@ -111,34 +102,21 @@ function makeCliDebugOptions(tag: string): { debug?: boolean; debugFile?: string
111
102
  };
112
103
  }
113
104
 
114
- /** Unconditional diagnostic dump — for "should never happen" paths */
105
+ /** Unconditional diagnostic dump — for "should never happen" paths. Creates pi's agent
106
+ * dir itself: callers run inside streamSimple, where a missing dir must not throw. */
115
107
  function diagDump(label: string, data: Record<string, unknown>) {
116
108
  const ts = new Date().toISOString();
117
109
  const entry = { ts, moduleInstanceId, label, ...data };
110
+ mkdirSync(dirname(DIAG_LOG_PATH), { recursive: true });
118
111
  appendFileSync(DIAG_LOG_PATH, JSON.stringify(entry) + "\n");
119
112
  debug(`DIAG: ${label} (see ${DIAG_LOG_PATH})`);
120
113
  }
121
114
 
122
115
  // --- Constants ---
123
116
 
124
- // Global key to prevent re-registration of the provider across module reloads.
125
- //
126
- // Extensions like pi-subagents spawn a subagent and it loads this module
127
- // again. Without this guard, the subagent's call to registerProvider() would
128
- // overwrite the parent's `streamSimple` function reference in the shared
129
- // ModelRegistry. When the parent later delivers a tool result, it would call
130
- // the subagent's `streamSimple` (which has empty state) instead of its own.
131
- //
132
- // By storing the active streamSimple in a Symbol.for() global (shared across all
133
- // module instances), we ensure only the FIRST instance to register takes effect.
134
- // Subsequent instances wrap the stored function instead of overwriting it.
135
- //
136
- // On session_shutdown (including /reload), clearSession() resets this so a fresh
137
- // registration can occur for the next session.
138
- //
139
- // The prompt-capture table is shared the same way (PROMPT_CAPTURES_KEY): skipping
140
- // re-registration is not enough when the first copy's before_agent_start handler
141
- // is dropped and a second copy records into a different Map.
117
+ // Marks which bridge module instance owns the registered provider's stream fn.
118
+ // Full registration policy (first vs later instances, shared vs own registry):
119
+ // see the "--- Provider ---" block in activate() below.
142
120
  const ACTIVE_STREAM_SIMPLE_KEY = Symbol.for("claude-bridge:activeStreamSimple");
143
121
 
144
122
  // MODELS is buildModels(getModels("anthropic")) — projection kept in models.js.
@@ -165,19 +143,25 @@ interface SessionState {
165
143
  sessionId: string;
166
144
  cursor: number;
167
145
  cwd: string;
146
+ // The pi session this CC conversation serves, from the provider call's
147
+ // options.sessionId. Attribution for history rewrites: a subagent's
148
+ // session_compact must not force a rebuild of a conversation that belongs
149
+ // to a different pi session. Null until a provider call recorded it.
150
+ piSessionId?: string;
168
151
  // Force the next syncSharedSession call down the REBUILD path. Set when
169
152
  // pi has mutated its messages array out from under us (compact, tree
170
153
  // navigation) or after an abort left the JSONL in an indeterminate state.
171
154
  // REBUILD wipes and rewrites the file to match pi's current history.
172
155
  needsRebuild?: boolean;
173
- // Set ONLY after an abort. The killed CC subprocess may still be flushing
174
- // a late "[Request interrupted by user]" record to the session JSONL.
175
- // Reusing the same sessionId/path would race that orphan write into our
176
- // fresh file and break CC's parent-uuid chain on the next resume. When
177
- // this flag is set, REBUILD takes a fresh UUID and skips deleteSession
178
- // so the orphan writes land on a dead inode. Compact/tree do NOT set
179
- // this — there's no concurrent CC writer during those events, so
180
- // in-place rebuild (preserve UUID, deleteSession + createSession) is safe.
156
+ // Set ONLY where we have just killed a CC subprocess: an abort, or a query
157
+ // discarded because pi rewrote the history under it. The killed subprocess
158
+ // may still be flushing a late "[Request interrupted by user]" record to the
159
+ // session JSONL. Reusing the same sessionId/path would race that orphan write
160
+ // into our fresh file and break CC's parent-uuid chain on the next resume.
161
+ // When this flag is set, REBUILD takes a fresh UUID and skips deleteSession
162
+ // so the orphan writes land on a dead inode. A compact or tree navigation
163
+ // with no query in flight does NOT set this — there's no concurrent CC writer
164
+ // then, so in-place rebuild (preserve UUID, deleteSession + createSession) is safe.
181
165
  forceRotate?: boolean;
182
166
  }
183
167
 
@@ -202,7 +186,105 @@ function readCarriedAttachments(sessionId: string, cwd: string): CarriedAttachme
202
186
  }
203
187
  }
204
188
 
205
- let sharedSession: SessionState | null = null;
189
+ /** The session key a pi session's provider calls address. Unattributed calls
190
+ * (no options.sessionId — a host that omits it)
191
+ * share the "(none)" bucket: they cannot be told apart, so they share the
192
+ * pre-existing single-slot semantics. */
193
+ function sessionKey(piSessionId: string | null | undefined): string {
194
+ return piSessionId ?? "(none)";
195
+ }
196
+
197
+ /** Mirror of the CC conversation one pi session's turns are running on. One
198
+ * entry per pi session: a bridge process serves several sessions at once
199
+ * (pi-subagents children run their own AgentSessions), and a single shared
200
+ * slot forced them to fight over it — a length-matching foreign sync could
201
+ * REUSE or rebuild another session's CC file, and the completion capture was
202
+ * last-writer-wins (a foreground child in the parent's first turn permanently
203
+ * reassigned the parent's conversation). Keyed lookup removes the fight: each
204
+ * session's reads, writes and teardown marks touch only its own mirror. */
205
+ const sharedSessions = new Map<string, SessionState>();
206
+
207
+ /** The mirror for `piSessionId`, or null when this session has none yet. */
208
+ function sessionStateFor(piSessionId: string | null | undefined): SessionState | null {
209
+ return sharedSessions.get(sessionKey(piSessionId)) ?? null;
210
+ }
211
+
212
+ /** Replace (or plant) the mirror for `piSessionId`. */
213
+ function setSessionStateFor(piSessionId: string | null | undefined, state: SessionState | null): void {
214
+ if (state === null) sharedSessions.delete(sessionKey(piSessionId));
215
+ else sharedSessions.set(sessionKey(piSessionId), state);
216
+ }
217
+
218
+ // pi replaced one of its sessions' history (compact, tree) rather than appending
219
+ // to it. Read on the tool-result path — the one provider call that never reaches
220
+ // syncSharedSession — so a query parked at a tool boundary is discarded rather
221
+ // than resumed (issue #101). Keyed by pi session id, not process-global: a bridge
222
+ // process serves several pi sessions at once (subagents run their own
223
+ // AgentSessions and can compact mid-run while the parent is parked), and
224
+ // marking across that boundary kills healthy queries (or, worse, rebuilds the
225
+ // parent around a compaction that never touched it).
226
+ //
227
+ // Holds real pi session ids only, never the "(none)" key: no production caller
228
+ // marks with null (the rewrite events attribute via ctx.sessionManager), and
229
+ // every reader guards on a non-null piSessionId — so a "(none)" entry could
230
+ // never be matched or consumed, only leaked.
231
+ const historyRewrittenBySession = new Set<string>();
232
+
233
+ /** Handlers that arm rewrite staleness, one per module instance. Worktree-
234
+ * spawned subagents can load this module fresh (pi's loader cache is keyed on
235
+ * cwd, and a worktree cwd clears it), while the serving instance — whose
236
+ * streamSimple the pi sessions actually call — is whoever registered first.
237
+ * A fresh instance must forward its session's rewrites to the serving one.
238
+ * Symbol.for: one registry per process, like SHARED_CAPTURES_KEY below. */
239
+ const MARK_REBUILD_HOOKS_KEY = Symbol.for("claude-bridge:markRebuildHooks");
240
+ type MarkRebuildHook = (piSession: string | null, event: string) => void;
241
+ const markRebuildHooks: Set<MarkRebuildHook> =
242
+ ((globalThis as Record<symbol, unknown>)[MARK_REBUILD_HOOKS_KEY] as Set<MarkRebuildHook> | undefined) ?? new Set();
243
+ (globalThis as Record<symbol, unknown>)[MARK_REBUILD_HOOKS_KEY] = markRebuildHooks;
244
+
245
+ /** pi mutated its messages array out from under us: force the next
246
+ * syncSharedSession down REBUILD, and arm the discard above. `piSession`
247
+ * is never null from an event handler (each pi session has its own runner).
248
+ * The "(none)" key still covers the mirror for a hypothetical direct caller
249
+ * with no session id, so such a rewrite forces the REBUILD side; the discard
250
+ * set holds real ids only — see the comment on historyRewrittenBySession. */
251
+ function markRebuildForSession(piSession: string | null, event: string): void {
252
+ // The rewriting session's own mirror: the rewrite changed the history it was
253
+ // built from, so its next sync must REBUILD rather than REUSE. Every other
254
+ // session's mirror stays untouched — its conversation was never rewritten.
255
+ const key = sessionKey(piSession);
256
+ const state = sharedSessions.get(key);
257
+ if (!state) {
258
+ debug(`${event}: history rewritten, no session to mark yet`);
259
+ } else {
260
+ sharedSessions.set(key, { ...state, needsRebuild: true });
261
+ debug(`${event}: marking needsRebuild on session ${state.sessionId.slice(0, 8)}`);
262
+ }
263
+ // Arming parked contexts cannot wait for delivery: the entry checks
264
+ // `resultCtx.historyStale`, and a rewrite usually lands *while* the query is
265
+ // parked (compaction runs inside pi's turn loop, not between provider calls).
266
+ if (piSession) historyRewrittenBySession.add(piSession);
267
+ armStaleContexts();
268
+ }
269
+
270
+ /** Copy each armed session's mark onto the parked queries built from it. */
271
+ function armStaleContexts(): void {
272
+ for (const c of activeQueryContexts) {
273
+ if (c.piSessionId && historyRewrittenBySession.has(c.piSessionId)) c.historyStale = true;
274
+ }
275
+ }
276
+
277
+ // This instance enlists. Session ids reaching any hook equal options.sessionId
278
+ // on the serving instance's provider calls, so forwarding is safe: only the
279
+ // owning instance's contexts and SessionState match the key.
280
+ markRebuildHooks.add(markRebuildForSession);
281
+
282
+ /** Event handlers call this: it fans the rewrite out to every module instance
283
+ * in the process, of which exactly one is serving provider traffic for any
284
+ * given pi session. */
285
+ function sponsorMarkRebuildForSession(piSession: string | null, event: string): void {
286
+ for (const hook of markRebuildHooks) hook(piSession, event);
287
+ }
206
288
 
207
289
  // Convert pi messages to Anthropic API format for session import.
208
290
  // Lossy: only text, thinking and toolCall blocks survive, and thinking only when
@@ -408,7 +490,7 @@ function resultErrorText(message: SDKMessage): string | undefined {
408
490
  *
409
491
  * pi has no typed rate-limit error — `stopReason` is only ever `"error"` and the sole carrier
410
492
  * is `errorMessage` — so everything that reacts to a rate limit pattern-matches that string:
411
- * pi-subagents gates `fallbackModels` on a 35-pattern list, and key-rotating extensions use
493
+ * pi-subagents gates `fallbackModels` on its own pattern list, and key-rotating extensions use
412
494
  * their own. Claude Code words a subscription limit as "You're out of extra usage · resets
413
495
  * 6:30pm", which matches none of them, so an exhausted quota reads as a fatal error and the
414
496
  * fallback chain never runs (issue #58).
@@ -418,12 +500,12 @@ function resultErrorText(message: SDKMessage): string | undefined {
418
500
  * failure and refuses to retry. */
419
501
  function describeRateLimitFailure(rejection: { rateLimitType?: string; resetsAt?: number }, failure: string): string {
420
502
  const kind = rejection.rateLimitType ? ` (${rejection.rateLimitType})` : "";
421
- const resets = rejection.resetsAt ? ` — resets ${new Date(rejection.resetsAt * 1000).toLocaleTimeString()}` : "";
503
+ const resets = rejection.resetsAt ? ` — resets ${new Date(rejection.resetsAt * 1000).toLocaleTimeString()}` : ""; // resetsAt: Unix seconds (unit undocumented in the SDK; observed)
422
504
  return `Claude rate limit${kind}${resets}: ${failure}`;
423
505
  }
424
506
 
425
507
  function standaloneStreamFn(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
426
- const stream = newAssistantMessageEventStream();
508
+ const stream = createAssistantMessageEventStream();
427
509
  void runStandaloneRequest(model, context, options, stream);
428
510
  return stream;
429
511
  }
@@ -450,9 +532,11 @@ async function runStandaloneRequest(
450
532
  if (claudeExecutableResolution.error) throw new Error(claudeExecutableResolution.error);
451
533
  const claudeExecutable = claudeExecutableResolution.path;
452
534
  const cliModel = claudeCodeModelId(model, longContextSettings);
535
+ const mapped = options?.reasoning ? model.thinkingLevelMap?.[options.reasoning] : undefined;
453
536
  const effort = options?.reasoning
454
- ? ((model as any).thinkingLevelMap?.[options.reasoning] as EffortLevel | undefined)
455
- ?? REASONING_TO_EFFORT[options.reasoning]
537
+ ? mapped === undefined
538
+ ? REASONING_TO_EFFORT[options.reasoning]
539
+ : VALID_EFFORTS.has(mapped as EffortLevel) ? mapped as EffortLevel : undefined
456
540
  : undefined;
457
541
  const extraArgs: Record<string, string | null> = {};
458
542
  if (effort || adaptiveThinkingAlwaysOn(model.id)) extraArgs["thinking-display"] = "summarized";
@@ -488,7 +572,7 @@ async function runStandaloneRequest(
488
572
  let assistantText = "";
489
573
  let finalText = "";
490
574
  let errorText: string | undefined;
491
- let resultUsage: Record<string, number | undefined> | undefined;
575
+ let resultUsage: SdkUsage | undefined;
492
576
  let firstEventLogged = false;
493
577
 
494
578
  for await (const message of sdkQuery) {
@@ -505,7 +589,7 @@ async function runStandaloneRequest(
505
589
  } else if (message.type === "result") {
506
590
  logServedContextWindow("standalone", message, model);
507
591
  errorText = resultErrorText(message);
508
- resultUsage = (message as SDKMessage & { usage?: Record<string, number | undefined> }).usage;
592
+ resultUsage = (message as SDKMessage & { usage?: SdkUsage }).usage;
509
593
  if (!errorText && message.subtype === "success") finalText = message.result || assistantText;
510
594
  }
511
595
  }
@@ -528,7 +612,7 @@ async function runStandaloneRequest(
528
612
  }
529
613
 
530
614
  const output = newAssistantOutput(model, text, "stop");
531
- if (resultUsage) updateUsage(output, resultUsage, model);
615
+ if (resultUsage) recordUsage(output, resultUsage, model);
532
616
  debug(`standalone: done textLen=${text.length}`);
533
617
  stream.push({ type: "done", reason: "stop", message: output });
534
618
  stream.end();
@@ -624,13 +708,23 @@ function debugSessionPaths(label: string, cwd: string, jsonlPath: string): void
624
708
  //
625
709
  // Log strings still say "Case 1/2/3/4" so existing diagnostics (int-cache.sh,
626
710
  // int-session-resume.mjs) keep grepping the same anchors.
627
- function syncSharedSession(
711
+ function syncSharedSession(
628
712
  messages: Context["messages"],
629
713
  cwd: string,
630
714
  customToolNameToSdk?: Map<string, string>,
631
715
  modelId?: string,
716
+ piSessionId?: string | null,
632
717
  ): SyncResult {
633
- const priorMessages = messages.slice(0, turnStart(messages)); // everything before the current user turn
718
+ // System messages are pi's transcript representation of prompt and tool state, not
719
+ // conversation history — they are never imported into a CC session, so exclude them from
720
+ // the history space (priorMessages, cursor, missed) everywhere below (issue #106).
721
+ // The mirror this sync coordinates belongs to the syncing pi session alone:
722
+ // every read and write below addresses sessionStateFor(piSessionId), so a
723
+ // foreign session's shape-matching context can never REUSE or rebuild another
724
+ // session's CC file.
725
+ const sharedSession = sessionStateFor(piSessionId);
726
+ const history = nonSystemMessages(messages);
727
+ const priorMessages = history.slice(0, turnStart(history)); // everything before the current user turn
634
728
 
635
729
  // REUSE path
636
730
  //
@@ -644,23 +738,27 @@ function syncSharedSession(
644
738
  const trailingAssistantOnly =
645
739
  missed.length === 1 && (missed[0] as { role?: string }).role === "assistant";
646
740
  if (missed.length === 0 || trailingAssistantOnly) {
647
- if (trailingAssistantOnly) {
648
- sharedSession = { ...sharedSession, cursor: priorMessages.length, cwd };
649
- }
650
- debug(`Case 3: ${trailingAssistantOnly ? "advanced cursor past trailing assistant, " : ""}resuming session ${sharedSession.sessionId.slice(0, 8)}, cursor=${sharedSession.cursor}`);
651
- debug(`syncResult: path=reuse sessionId=${sharedSession.sessionId} cursor=${sharedSession.cursor}`);
652
- return { sessionId: sharedSession.sessionId };
741
+ if (trailingAssistantOnly) {
742
+ setSessionStateFor(piSessionId, { ...sharedSession, cursor: priorMessages.length, cwd });
743
+ debug(`Case 3: advanced cursor past trailing assistant, resuming session ${sharedSession.sessionId.slice(0, 8)}, cursor=${priorMessages.length}`);
744
+ } else {
745
+ debug(`Case 3: resuming session ${sharedSession.sessionId.slice(0, 8)}, cursor=${sharedSession.cursor}`);
746
+ }
747
+ debug(`syncResult: path=reuse sessionId=${sharedSession.sessionId} cursor=${sharedSession?.cursor}`);
748
+ return { sessionId: sharedSession.sessionId };
653
749
  }
654
750
  }
655
- // This is what keeps a reentrant subagent from taking over the parent's
656
- // session: a subagent starts with priors of its own, shorter than the parent's
657
- // cursor, so it lands here, gets a fresh session, and the ephemeral session it
658
- // captures is deleted once its query completes (see preserveSharedSession in
659
- // the completion handler). Remove this branch and a subagent resumes — then
660
- // overwrites — the parent's session.
751
+ // This is what keeps a caller with a pruned or short context from resuming
752
+ // — then overwriting — the bucket's session: shorter-than-cursor means the
753
+ // incoming history cannot be a continuation, so start clean and preserve.
754
+ // Historically this also caught reentrant subagents (a subagent's priors are
755
+ // shorter than the parent's cursor); with per-session mirrors it now catches
756
+ // the pruned-context shapes on a session's own bucket, and the non-isolated
757
+ // callers without a session id on the "(none)" bucket. The captured ephemeral session is
758
+ // deleted once its query completes (see preserveSharedSession in the
759
+ // completion handler).
661
760
  //
662
- // It is not the standalone no-cache path: runStandaloneRequest never calls
663
- // syncSharedSession at all.
761
+ // Standalone completions never call syncSharedSession.
664
762
  //
665
763
  // Only reachable when needsRebuild is false — user-facing history rewrites
666
764
  // (/compact, session_tree, /new, fork) always set needsRebuild or clear
@@ -673,7 +771,7 @@ function syncSharedSession(
673
771
 
674
772
  // REBUILD path
675
773
  if (priorMessages.length === 0) {
676
- debug(`Case 1: clean start, ${messages.length} total messages`);
774
+ debug(`Case 1: clean start, ${history.length} total messages`);
677
775
  debug(`syncResult: path=clean-start`);
678
776
  return { sessionId: null };
679
777
  }
@@ -681,8 +779,8 @@ function syncSharedSession(
681
779
  const previousCursor = sharedSession?.cursor ?? 0;
682
780
  // preserveId: rebuild in place (deleteSession + createSession with the
683
781
  // existing UUID), so prompt-cache UUIDs stay stable for log correlation
684
- // and for any tools that key off them. Skipped only when there's a
685
- // concurrent writer we shouldn't race — see forceRotate docs above.
782
+ // and for any tools that key off them. Skipped when there's a concurrent
783
+ // writer we shouldn't race (forceRotate).
686
784
  const preserveId = previousSessionId !== undefined && !sharedSession?.forceRotate;
687
785
  // Before deleteSession — it wipes the file these live in.
688
786
  const carried = previousSessionId !== undefined ? readCarriedAttachments(previousSessionId, cwd) : [];
@@ -701,7 +799,7 @@ function syncSharedSession(
701
799
  // records, not messages: `messages` filters out the attachment records that
702
800
  // carrying an `@file` expansion across a rebuild writes into the same file.
703
801
  verifyWrittenSession(session.jsonlPath, session.sessionId, session.records.length, cwd);
704
- sharedSession = { sessionId: session.sessionId, cursor: priorMessages.length, cwd };
802
+ setSessionStateFor(piSessionId, { sessionId: session.sessionId, cursor: priorMessages.length, cwd, piSessionId: piSessionId ?? undefined });
705
803
  if (previousSessionId === undefined) {
706
804
  debug(`Case 2: first turn with ${priorMessages.length} prior messages → session ${session.sessionId.slice(0, 8)}, ${session.records.length} records`);
707
805
  } else if (preserveId) {
@@ -715,20 +813,45 @@ function syncSharedSession(
715
813
  return { sessionId: session.sessionId };
716
814
  }
717
815
 
816
+ // The SDK's query(), or a test double (see setQuery). The compact/summary
817
+ // path calls the real query() directly — its subprocess must never be swapped
818
+ // out from under a real compaction.
819
+ let queryImpl: typeof query = query;
820
+
718
821
  // @internal
719
822
  export const __test = {
720
- resetSharedSession() {
721
- sharedSession = null;
823
+ setQuery(fn: typeof query | null) {
824
+ queryImpl = fn ?? query;
722
825
  },
723
- setSharedSession(state: SessionState | null) {
724
- sharedSession = state;
826
+ resetSharedSession(piSessionId?: string | null) {
827
+ // No id: full reset (the pre-map semantics — tests start from a blank slate).
828
+ if (piSessionId === undefined) sharedSessions.clear();
829
+ else setSessionStateFor(piSessionId, null);
830
+ historyRewrittenBySession.clear();
725
831
  },
726
- getSharedSession() {
727
- return sharedSession;
832
+ markRebuildForSession,
833
+ getHistoryRewritten: () => historyRewrittenBySession.size > 0,
834
+ historyRewrittenBySession,
835
+ armStaleContexts,
836
+ discardRewrittenQuery,
837
+ contextForToolResults,
838
+ isQueryAbandoned: (q: object) => abandonedQueries.has(q),
839
+ get activeQueryContexts() {
840
+ return activeQueryContexts;
841
+ },
842
+ setSharedSession(piSessionId: string | null, state: SessionState | null) {
843
+ setSessionStateFor(piSessionId, state);
844
+ },
845
+ getSharedSession(piSessionId: string | null = null) {
846
+ return sessionStateFor(piSessionId);
847
+ },
848
+ setPiModelRegistry(registry: AnthropicAuthRegistry | null) {
849
+ piModelRegistry = registry;
728
850
  },
729
851
  setPiUI(ui: ExtensionUIContext | null) {
730
852
  piUI = ui;
731
853
  },
854
+ toBridgeContext,
732
855
  syncSharedSession,
733
856
  extractUserPromptBlocks,
734
857
  consumeQuery,
@@ -740,6 +863,9 @@ export const __test = {
740
863
  buildMcpServers,
741
864
  isStandaloneRequest,
742
865
  extractStandalonePrompt,
866
+ get promptCaptures() {
867
+ return promptCaptures;
868
+ },
743
869
  };
744
870
 
745
871
  // --- Provider helpers: tool name mapping ---
@@ -794,11 +920,11 @@ let piMode: ExtensionContext["mode"] | null = null;
794
920
  let piModelRegistry: AnthropicAuthRegistry | null = null;
795
921
  const activeQueryContexts = new Set<QueryContext>();
796
922
 
797
- // `plan` is the one setting whose default can cost a Pro user something (Opus
798
- // 4.6 at 1M when they only have 200K), so announce it once. Deferred to the first
799
- // provider query rather than session_start: the notice persists a flag to the global
800
- // config, and firing it on startup would write that file for every pi session that
801
- // merely has this extension installed.
923
+ // The assumed Max plan is announced once. Deferred to the first bridge query rather
924
+ // than session_start: the notice persists a flag to the global config, and
925
+ // firing it on startup would write that file for every pi session that merely
926
+ // has this extension installed. One message, because consecutive info notifies
927
+ // overwrite each other in the TUI.
802
928
  let pendingNotices: string[] = [];
803
929
 
804
930
  function showStartupNoticeOnce(): void {
@@ -816,10 +942,10 @@ function showStartupNoticeOnce(): void {
816
942
  }
817
943
 
818
944
  // Captures of what pi assembled per agent; see src/prompt-capture.ts for why this
819
- // is keyed rather than held in a single slot. Process-wide: a second evaluation
820
- // of this module (user vs project package root, or a subagent) must record into
821
- // the same table the first evaluation's streamSimple reads.
822
- const promptCaptures = getSharedPromptCaptures(() => new PromptCaptures(256, (diagnostic) => {
945
+ // is keyed rather than held in a single slot. One process-wide instance, shared
946
+ // across every extension module instance: isolated subagents re-evaluate this
947
+ // module, and the pinned stream they all route through resolves against it.
948
+ const promptCaptures = sharedPromptCaptures((diagnostic) => {
823
949
  const first = diagnostic.matches[0];
824
950
  debug(
825
951
  `prompt-capture: no match for ${diagnostic.systemPrompt.length}-char system prompt. `
@@ -829,7 +955,7 @@ const promptCaptures = getSharedPromptCaptures(() => new PromptCaptures(256, (di
829
955
  : "no known captures to compare against."
830
956
  ) + ` known keys=${diagnostic.matches.length}`,
831
957
  );
832
- }));
958
+ });
833
959
 
834
960
  /** Whatever a settled session left behind, named in one greppable line.
835
961
  *
@@ -920,19 +1046,11 @@ function buildMcpServers(tools: Tool[], queryCtx: QueryContext): Record<string,
920
1046
 
921
1047
  // --- Usage helpers ---
922
1048
 
923
- function updateUsage(output: AssistantMessage, usage: Record<string, number | undefined>, model: Model<any>): void {
924
- if (usage.input_tokens != null) output.usage.input = usage.input_tokens;
925
- if (usage.output_tokens != null) output.usage.output = usage.output_tokens;
926
- if (usage.cache_read_input_tokens != null) output.usage.cacheRead = usage.cache_read_input_tokens;
927
- if (usage.cache_creation_input_tokens != null) output.usage.cacheWrite = usage.cache_creation_input_tokens;
928
- // Claude Code may report reasoning/thinking tokens separately, while pi's Usage type does not model that field.
929
- const reasoning = usage.reasoning_tokens ?? usage.thinking_tokens;
930
- if (reasoning != null) (output.usage as typeof output.usage & { reasoning?: number }).reasoning = reasoning;
931
- output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
932
- calculateCost(model, output.usage);
933
- const promptTokens = output.usage.input + output.usage.cacheRead + output.usage.cacheWrite;
934
- const cachePct = promptTokens > 0 ? Math.round(output.usage.cacheRead / promptTokens * 100) : 0;
935
- const reasoningText = reasoning != null ? ` reasoning=${reasoning}` : "";
1049
+ // The counter mapping lives in usage.ts; the debug line is this side's job, so every
1050
+ // call site logs the same way rather than three times over.
1051
+ function recordUsage(output: AssistantMessage, usage: SdkUsage, model: Model<any>): void {
1052
+ const { cachePct, reasoning } = updateUsage(output, usage, model);
1053
+ const reasoningText = reasoning == null ? "" : ` reasoning=${reasoning}`;
936
1054
  debug(`usage: in=${output.usage.input} out=${output.usage.output} cacheRead=${output.usage.cacheRead} cacheWrite=${output.usage.cacheWrite} total=${output.usage.totalTokens}${reasoningText} cachePct=${cachePct}% model=${model.id}`);
937
1055
  }
938
1056
 
@@ -957,6 +1075,8 @@ const REASONING_TO_EFFORT: Record<string, EffortLevel> = {
957
1075
  minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "max",
958
1076
  };
959
1077
 
1078
+ const VALID_EFFORTS = new Set<string>(["low", "medium", "high", "xhigh", "max"]);
1079
+
960
1080
  // --- Provider helpers: misc ---
961
1081
 
962
1082
  function mapStopReason(reason: string | undefined): "stop" | "length" | "toolUse" {
@@ -1037,8 +1157,14 @@ function processStreamEvent(
1037
1157
  const event = (message as SDKMessage & { event: any }).event;
1038
1158
 
1039
1159
  if (event?.type === "message_start") {
1160
+ // Still open from an earlier message_start: Claude Code gave up on that
1161
+ // stream and is retrying it. Its blocks were never completed.
1162
+ if (c.turnStreamOpen) dropAbandonedStreamBlocks(c, `restreamed as ${event.message?.id}`);
1040
1163
  c.turnToolCallIds = [];
1041
- if (event.message?.usage) updateUsage(c.turnOutput, event.message.usage, model);
1164
+ c.turnStreamMessageId = event.message?.id;
1165
+ c.turnStreamOpen = true;
1166
+ c.turnStreamBlockStart = c.turnBlocks.length;
1167
+ if (event.message?.usage) recordUsage(c.turnOutput, event.message.usage, model);
1042
1168
  return;
1043
1169
  }
1044
1170
 
@@ -1115,10 +1241,12 @@ function processStreamEvent(
1115
1241
 
1116
1242
  if (event?.type === "message_delta") {
1117
1243
  c.turnOutput.stopReason = mapStopReason(event.delta?.stop_reason);
1118
- if (event.usage) updateUsage(c.turnOutput, event.usage, model);
1244
+ if (event.usage) recordUsage(c.turnOutput, event.usage, model);
1119
1245
  return;
1120
1246
  }
1121
1247
 
1248
+ if (event?.type === "message_stop") c.turnStreamOpen = false;
1249
+
1122
1250
  if (event?.type === "message_stop" && c.turnSawToolCall) {
1123
1251
  // Tool call complete — end this pi stream. The SDK will still yield an
1124
1252
  // assistant message for this turn, but currentPiStream=null causes
@@ -1141,16 +1269,46 @@ function processStreamEvent(
1141
1269
  }
1142
1270
  }
1143
1271
 
1272
+ /** Remove the blocks a stream Claude Code abandoned mid-message. They never got a
1273
+ * message_stop, so a thinking block has no signature and a tool call is one CC will
1274
+ * never dispatch; left in, pi would run the tool and the turn would wait on a
1275
+ * handler that never comes, or the next request would replay a broken block.
1276
+ * The fallback then restarts those indices. pi's normal provider path tolerates that;
1277
+ * pi-agent-core's experimental harness frame encoder keys blocks by contentIndex and
1278
+ * rejects a repeated start, so it would need a change there to drive this provider. */
1279
+ function dropAbandonedStreamBlocks(c: QueryContext, why: string): void {
1280
+ const dropped = c.turnBlocks.splice(c.turnStreamBlockStart);
1281
+ debug(`dropAbandonedStreamBlocks: ${why}; dropped ${dropped.length} blocks from ${c.turnStreamMessageId} types=${dropped.map((b: any) => b.type).join(",")}`);
1282
+ c.turnToolCallIds = [];
1283
+ c.turnSawToolCall = c.turnBlocks.some((b: any) => b.type === "toolCall");
1284
+ c.turnStreamOpen = false;
1285
+ }
1286
+
1144
1287
  // The SDK always yields `assistant` messages (completed content blocks) after streaming.
1145
1288
  // When stream_events already delivered the content, this is a no-op. But after
1146
1289
  // resetTurnState (e.g. tool result delivery), if the next turn's assistant message
1147
1290
  // arrives before any stream_events, this is the primary content path. Must maintain
1148
1291
  // the same stream lifecycle as processStreamEvent — including ending the stream on
1149
1292
  // tool_use to prevent deadlock with the MCP handler.
1293
+ //
1294
+ // It is also the content path when a stream stalls: Claude Code drops it and asks
1295
+ // again without streaming ("Error streaming, falling back to non-streaming mode"),
1296
+ // and the answer arrives as one assistant message, under a new message id, with no
1297
+ // stream_events of its own. turnSawStreamEvent is already set by the dead stream,
1298
+ // so gating on it alone dropped that message: its tool calls never reached pi, CC
1299
+ // sat in the MCP handler waiting for their results, and the turn hung on "Working"
1300
+ // until the user aborted it.
1150
1301
  function processAssistantMessage(message: SDKMessage, model: Model<any>, customToolNameToPi: Map<string, string>, c: QueryContext): void {
1151
- if (c.turnSawStreamEvent) return;
1152
1302
  const assistantMsg = (message as any).message;
1153
1303
  if (!assistantMsg?.content) return;
1304
+ if (c.turnSawStreamEvent) {
1305
+ // Same id was already delivered; a new id is CC's non-streaming fallback.
1306
+ // Drop the stalled stream's partial blocks if it never stopped. Deliberately
1307
+ // deliver even if it stopped, at the risk of duplication if CC renumbers it.
1308
+ const id = assistantMsg.id;
1309
+ if (!id || !c.turnStreamMessageId || id === c.turnStreamMessageId) return;
1310
+ if (c.turnStreamOpen) dropAbandonedStreamBlocks(c, `non-streaming fallback ${id}`);
1311
+ }
1154
1312
  c.turnToolCallIds = [];
1155
1313
  debug(`processAssistantMessage fallback: ${assistantMsg.content.length} blocks, types=${assistantMsg.content.map((b: any) => b.type).join(",")}`);
1156
1314
  for (const block of assistantMsg.content) {
@@ -1190,7 +1348,7 @@ function processAssistantMessage(message: SDKMessage, model: Model<any>, customT
1190
1348
  debug("processAssistantMessage: unhandled block type", block.type);
1191
1349
  }
1192
1350
  }
1193
- if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model);
1351
+ if (assistantMsg.usage && c.turnOutput) recordUsage(c.turnOutput, assistantMsg.usage, model);
1194
1352
 
1195
1353
  // End the stream on tool_use, same as processStreamEvent's message_stop handler.
1196
1354
  if (c.turnSawToolCall && c.currentPiStream && c.turnOutput) {
@@ -1344,10 +1502,11 @@ function steerBlocks(messages: Context["messages"]): ContentBlockParam[] | null
1344
1502
  /** A steer that never made it into CC's session. The cursor has already counted
1345
1503
  * it, so count-based sync would skip it forever — rebuild instead, which
1346
1504
  * re-imports the message from pi's context. */
1347
- function steerMissedSession(text: string): void {
1348
- if (!sharedSession) return;
1349
- sharedSession = { ...sharedSession, needsRebuild: true };
1350
- debug(`provider: steer never reached CC, marked session for rebuild: ${text.slice(0, 60)}`);
1505
+ function steerMissedSession(c: QueryContext, text: string): void {
1506
+ c.missedSteer = true;
1507
+ const state = sessionStateFor(c.piSessionId);
1508
+ if (state) setSessionStateFor(c.piSessionId, { ...state, needsRebuild: true });
1509
+ debug(`provider: steer never reached CC, marked query for rebuild: ${text.slice(0, 60)}`);
1351
1510
  }
1352
1511
 
1353
1512
  /** Releases this turn's tool results to their MCP handlers, after first pushing
@@ -1373,7 +1532,7 @@ async function deliverToolResults(
1373
1532
  const text = steer.map((b) => (b.type === "text" ? b.text : "[image]")).join("\n");
1374
1533
  if (!c.promptStream) {
1375
1534
  debug(`WARNING: steer with no prompt stream, dropping: ${text.slice(0, 60)}`);
1376
- steerMissedSession(text);
1535
+ steerMissedSession(c, text);
1377
1536
  } else {
1378
1537
  try {
1379
1538
  await c.promptStream.push(userMessage(steer, "next"));
@@ -1384,7 +1543,7 @@ async function deliverToolResults(
1384
1543
  // pi's context, and the caller has already advanced the session
1385
1544
  // cursor past it, so force a rebuild or CC would never see it.
1386
1545
  debug(`provider: steer push rejected, delivering tool result anyway:`, error);
1387
- steerMissedSession(text);
1546
+ steerMissedSession(c, text);
1388
1547
  }
1389
1548
  }
1390
1549
  }
@@ -1422,25 +1581,100 @@ function drainForAbort(c: QueryContext, promptStream: PromptStream): void {
1422
1581
  c.releasePendingToolCalls("Operation aborted");
1423
1582
  }
1424
1583
 
1425
- /** Provider entry point. Pi calls this for normal agent turns, but also for
1426
- * standalone no-cache completions made by compaction and extensions. Route the
1427
- * latter before touching prompt captures or resumable-session state. */
1584
+ /** Queries pi's history moved out from under. Their completion must not touch
1585
+ * `sharedSession` or the pi stream: the query that took over the turn has
1586
+ * already rebuilt both from the new history, and this one's session id names the
1587
+ * conversation pi just discarded. */
1588
+ const abandonedQueries = new WeakSet<object>();
1589
+
1590
+ /** The prompt a continuation query is opened with: the pi turn goes on, but its
1591
+ * last message is a tool result rather than a prompt, and query() cannot resume
1592
+ * a session without one. */
1593
+ const CONTINUE_AFTER_REWRITE_PROMPT =
1594
+ "[Your context was compacted. What precedes this is a summary plus the most recent messages, "
1595
+ + "ending with the tool result you were waiting for. Continue the task from there.]";
1596
+
1597
+ /** Drop a Claude Code query parked at a tool boundary whose conversation pi has
1598
+ * since rewritten (/compact, tree navigation).
1599
+ *
1600
+ * Delivering the turn's tool result into that query hands Claude Code the
1601
+ * context pi just shrank: one pi turn is one CC query, and the query keeps its
1602
+ * own context inside the CLI whatever pi does to its transcript. It answers off
1603
+ * the pre-compaction conversation, reports the pre-compaction usage back, and pi
1604
+ * crosses the same threshold at the next boundary — measured as one compaction
1605
+ * per tool call with usage never dropping (issue #101). `needsRebuild` does not
1606
+ * prevent it: only syncSharedSession reads that flag, and tool-result delivery
1607
+ * is the one call that never syncs.
1608
+ *
1609
+ * The caller then takes the fresh-query path, where REBUILD imports pi's
1610
+ * rewritten history — this tool result included, since it is already in that
1611
+ * history — so the turn continues instead of ending here. Nothing is lost by
1612
+ * killing the subprocess: pi owns the only copy of the conversation that counts. */
1613
+ function discardRewrittenQuery(c: QueryContext): void {
1614
+ const discarded = c.activeQuery as { interrupt?: () => Promise<unknown>; close?: () => void } | null;
1615
+ if (discarded) abandonedQueries.add(discarded);
1616
+ c.activeQuery = null;
1617
+ // Leaving the routing set is what stops this result coming straight back here:
1618
+ // contextForToolResults only matches ids against contexts still in it.
1619
+ activeQueryContexts.delete(c);
1620
+ c.turnToolCallIds = [];
1621
+ c.promptStream?.fail(new Error("conversation rewritten"));
1622
+ c.promptStream = null;
1623
+ // Settle the parked handlers before killing the CLI, for drainForAbort's
1624
+ // reason: one left awaiting a dead subprocess never settles.
1625
+ c.releasePendingToolCalls("Context was compacted; this query was discarded.");
1626
+ void discarded?.interrupt?.().catch(() => {});
1627
+ try { discarded?.close?.(); } catch {}
1628
+ // The CLI we just killed may still flush a record into the session JSONL, and
1629
+ // the rebuild is the next thing that happens — so rotate rather than race it,
1630
+ // exactly as after an abort. Only this session's mirror: the discarding query
1631
+ // proves its own conversation is the one being rebuilt around.
1632
+ const state = sessionStateFor(c.piSessionId);
1633
+ if (state) setSessionStateFor(c.piSessionId, { ...state, needsRebuild: true, forceRotate: true });
1634
+ if (c.piSessionId) historyRewrittenBySession.delete(c.piSessionId);
1635
+ debug("provider: history rewritten under a parked query — discarded it, rebuilding from current history");
1636
+ }
1637
+
1638
+ /** Provider entry point. Pi calls this for each new prompt and each tool result.
1639
+ * Two cases: tool result delivery (active query) or fresh query. */
1428
1640
  function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
1641
+ // pi hands providers a transcript (prompt/tools folded into system messages) — fold it
1642
+ // back out to the prompt/tools fields every cursor write, syncSharedSession call and
1643
+ // prompt-capture lookup below assumes (issue #106).
1644
+ context = toBridgeContext(context);
1645
+
1646
+ // Normalize transcript-shaped summaries before recognizing the tool-free no-cache
1647
+ // call. Keep nested extension completions and summaries out of live session state.
1429
1648
  if (isStandaloneRequest(context, options)) {
1430
- debug(`provider: routing standalone cacheRetention=none request to isolated subprocess`);
1649
+ debug("provider: routing standalone cacheRetention=none request to isolated subprocess");
1431
1650
  return standaloneStreamFn(model, context, options);
1432
1651
  }
1433
1652
 
1434
1653
  showStartupNoticeOnce();
1435
- const stream = newAssistantMessageEventStream();
1654
+ const stream = createAssistantMessageEventStream();
1436
1655
 
1437
1656
  // DEBUG: trace followUp message triggering
1438
1657
  const lastMsgRole = context.messages[context.messages.length - 1]?.role;
1439
1658
  debug(`provider: streamClaudeAgentSdk called, activeQuery=${!!ctx().activeQuery}, lastMsgRole=${lastMsgRole}, isReentrant=${ctx().activeQuery !== null}`);
1440
1659
 
1441
- const activeQuery = ctx().activeQuery !== null;
1660
+ let activeQuery = ctx().activeQuery !== null;
1442
1661
  const allResults = activeQueryContexts.size > 0 ? extractAllToolResults(context) : [];
1443
- const resultCtx = allResults.length > 0 ? contextForToolResults(allResults) : undefined;
1662
+ let resultCtx = allResults.length > 0 ? contextForToolResults(allResults) : undefined;
1663
+
1664
+ // pi rewrote its history while this query sat parked at a tool boundary, so the
1665
+ // query answers about a conversation that no longer exists. Discard it and let
1666
+ // this tool result carry the turn into a fresh query over the rewritten history.
1667
+ // The staleness mark is per pi session: a subagent's compaction (its own
1668
+ // AgentSession, sharing this process) must not discard the parent's parked
1669
+ // query, and vice versa.
1670
+ const rewrittenUnderQuery = Boolean(resultCtx?.historyStale);
1671
+ if (resultCtx && rewrittenUnderQuery) {
1672
+ discardRewrittenQuery(resultCtx);
1673
+ resultCtx = undefined;
1674
+ // Recomputed, not cleared: a reentrant subagent may still hold a query of its own.
1675
+ activeQuery = ctx().activeQuery !== null;
1676
+ }
1677
+
1444
1678
  const isReentrantUserQuery = activeQuery && lastMsgRole === "user" && allResults.length === 0;
1445
1679
  if (isReentrantUserQuery) {
1446
1680
  debug(`provider: active query user-only call treated as reentrant fresh query, waitingHandlers=${ctx().pendingToolCalls.size}, ctx.msgs=${context.messages.length}`);
@@ -1453,6 +1687,11 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1453
1687
  if (resultCtx) {
1454
1688
  claimCurrentPiStream(stream, "tool-result", resultCtx);
1455
1689
  resultCtx.resetTurnState(model);
1690
+ // A rewrite that armed the mark after this query parked gets copied here,
1691
+ // though markRebuildForSession usually reaches it directly.
1692
+ if (!resultCtx.historyStale && resultCtx.piSessionId && historyRewrittenBySession.has(resultCtx.piSessionId)) {
1693
+ resultCtx.historyStale = true;
1694
+ }
1456
1695
  // User messages (steer/followUp) pi injected into context during the
1457
1696
  // active query: a steer sent while a tool was executing, drained by pi at
1458
1697
  // the turn boundary and appended alongside the tool result.
@@ -1465,18 +1704,26 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1465
1704
  // delivering its own results would drag it to that subagent's message count
1466
1705
  // — observed pulling a parent from 5 back to 3, which cost the parent's next
1467
1706
  // turn a full rebuild and a flushed prompt cache.
1468
- if (sharedSession && resultCtx === ctx()) sharedSession.cursor = context.messages.length;
1707
+ const state = sessionStateFor(resultCtx.piSessionId);
1708
+ if (state) state.cursor = context.messages.length;
1469
1709
  resultCtx.latestCursor = Math.max(resultCtx.latestCursor, context.messages.length);
1470
1710
  return stream;
1471
1711
  }
1472
1712
 
1473
1713
  // --- Orphaned tool result (e.g. user aborted a tool call) ---
1474
1714
  // The query is gone but pi still delivered the result. Nothing to do — just
1475
- // emit end_turn so pi waits for the next real user message.
1715
+ // emit end_turn so pi waits for the next real user message. The discard
1716
+ // branch above already siphoned off the stale-query case, which goes on to a
1717
+ // rebuild instead — that one has somewhere to deliver the result to.
1476
1718
  const lastMsg = context.messages[context.messages.length - 1];
1477
- if (lastMsg?.role === "toolResult") {
1719
+ if (lastMsg?.role === "toolResult" && !rewrittenUnderQuery) {
1478
1720
  debug(`provider: orphaned tool result after abort, emitting end_turn`);
1479
- if (sharedSession && activeQueryContexts.size === 0) sharedSession.cursor = context.messages.length;
1721
+ // With no query in flight anywhere, the top-level session this result
1722
+ // belongs to is the one whose turn just ended: its cursor advances to
1723
+ // count the result (options.sessionId is that session — pi emits the
1724
+ // result event through the same session's streamSimple call).
1725
+ const orphanState = sessionStateFor(options?.sessionId ?? null);
1726
+ if (orphanState && activeQueryContexts.size === 0) orphanState.cursor = context.messages.length;
1480
1727
  // No query owns this result, so there is no context to reset: resetTurnState
1481
1728
  // on the top-level ctx() would replace a live parent's turnOutput mid-stream,
1482
1729
  // stranding the blocks it had already emitted. A throwaway context just
@@ -1499,33 +1746,53 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1499
1746
  const queryCtx = isReentrant ? new QueryContext() : ctx();
1500
1747
  debug(`provider: fresh query setup, isReentrant=${isReentrant}, activeContexts=${activeQueryContexts.size}`);
1501
1748
 
1502
- // Fail before claiming a stream if this model needs a newer CLI than we have.
1503
1749
  const claudeExecutableResolution = resolveClaudeCodeExecutable(model.id, providerSettings.pathToClaudeCodeExecutable);
1504
1750
  if (claudeExecutableResolution.error) {
1505
- debug(`provider: ${claudeExecutableResolution.error}`);
1506
1751
  stream.push({ type: "error", reason: "error", error: newAssistantOutput(model, "", "error", claudeExecutableResolution.error) });
1507
1752
  stream.end();
1508
1753
  return stream;
1509
1754
  }
1510
1755
  const claudeExecutable = claudeExecutableResolution.path;
1511
- if (claudeExecutableResolution.source === "path") {
1512
- debug(`provider: using PATH claude ${claudeExecutable} (bundled CLI is too old for ${model.id})`);
1513
- }
1514
1756
 
1515
- // Resolved first: an unaccountable system prompt throws, and doing that before
1516
- // anything is claimed or reset leaves no half-built query behind — in particular
1517
- // no stream claimed on the shared context that nobody will ever end.
1757
+ // Resolved first: an unaccountable system prompt fails this query before anything
1758
+ // is claimed or reset, leaving no half-built query behind — in particular no stream
1759
+ // claimed on the shared context that nobody will ever end.
1518
1760
  const { mcpTools, customToolNameToSdk, customToolNameToPi } = resolveMcpTools(context);
1519
1761
  // Build from what Pi loaded for this run, so `--no-context-files` and
1520
1762
  // `--no-skills` reach Claude Code by leaving nothing to forward. A sub-agent's
1521
1763
  // custom override embeds its parent's assembled Pi prompt; recursive projection
1522
1764
  // replaces that exact inherited prompt with its already-safe portable parts.
1523
- const promptCapture = promptCaptures.resolveOrDerive(context.systemPrompt);
1524
- const systemPromptAppend = promptCapture
1525
- ? projectPromptCapture(promptCapture, {
1526
- skillReadTool: mcpTools.some((tool) => tool.name === "read") ? "mcp" : "none",
1527
- })
1528
- : undefined;
1765
+ // Derive the key from the transcript replay (toBridgeContext), NOT from the
1766
+ // recorded keys: under a forced prompt the transcript head is projected via
1767
+ // transformContext after turn_start, so ctx.getSystemPrompt() is not the head.
1768
+ let promptCapture: PromptCapture | undefined;
1769
+ let systemPromptAppend: string | undefined;
1770
+ try {
1771
+ promptCapture = promptCaptures.resolveOrDerive(context.systemPrompt);
1772
+ systemPromptAppend = promptCapture
1773
+ ? projectPromptCapture(promptCapture, {
1774
+ skillReadTool: mcpTools.some((tool) => tool.name === "read") ? "mcp" : "none",
1775
+ })
1776
+ : undefined;
1777
+ } catch (err) {
1778
+ // resolveOrDerive and projectPromptCapture throw to stop a turn that would lose
1779
+ // its instructions or leak pi's harness text. Report it on the stream, as pi-ai's
1780
+ // provider contract expects, so any caller — not only pi's agent loop, which
1781
+ // catches a throw — sees a failed turn rather than a synchronous exception.
1782
+ const output = newAssistantOutput(model, "", "error", errorMessage(err));
1783
+ queueMicrotask(() => {
1784
+ stream.push({ type: "error", reason: "error", error: output });
1785
+ markStreamComplete(stream);
1786
+ stream.end();
1787
+ });
1788
+ diagDump("prompt_capture_unresolved", {
1789
+ promptChars: context.systemPrompt?.length ?? 0,
1790
+ knownKeys: promptCaptures.size,
1791
+ reentrantUserQuery: isReentrantUserQuery,
1792
+ error: errorMessage(err),
1793
+ });
1794
+ return stream;
1795
+ }
1529
1796
 
1530
1797
  // 2. Fresh child context — constructor already gave us clean Maps and empty
1531
1798
  // arrays. For a reused top-level context, clear explicitly.
@@ -1538,16 +1805,41 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1538
1805
  queryCtx.turnToolCallIds = [];
1539
1806
  queryCtx.resetTurnState(model);
1540
1807
  queryCtx.latestCursor = 0;
1541
-
1542
- const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
1808
+ // The served pi session, for rewrite attribution on delivery (issue #101
1809
+ // follow-up) and on SessionState. A fresh instance of this module inside a
1810
+ // worktree-spawned subagent has its own contexts; each records its own.
1811
+ queryCtx.piSessionId = options?.sessionId ?? null;
1812
+ // A discarded query's replacement reuses this context; without the reset its
1813
+ // first tool result would sit on armed staleness again (the mark is consumed
1814
+ // from the set, not from here) and re-discard a healthy query.
1815
+ queryCtx.historyStale = false;
1816
+ queryCtx.missedSteer = false;
1817
+
1818
+ const cwd = process.cwd();
1543
1819
  // cliModel is the actual id sent to Claude Code (may carry [1m]); model.id is the
1544
1820
  // pi-registered id. Log cliModel so debug lines reflect what CC actually received.
1545
1821
  const cliModel = claudeCodeModelId(model, longContextSettings);
1546
- const syncResult = syncSharedSession(context.messages, cwd, customToolNameToSdk, cliModel);
1822
+ // Which pi session this query serves — the attribution key for history
1823
+ // rewrites (session_compact / session_tree) and for SessionState above.
1824
+ const piSessionId = options?.sessionId ?? null;
1825
+ const syncResult = syncSharedSession(context.messages, cwd, customToolNameToSdk, cliModel, piSessionId);
1826
+ // This query starts from the history pi has now: consume this session's
1827
+ // armed rewrite — a sibling pi session's stays armed for its own queries.
1828
+ if (piSessionId) historyRewrittenBySession.delete(piSessionId);
1547
1829
  const { sessionId: resumeSessionId } = syncResult;
1548
1830
  const promptBlocks = extractUserPromptBlocks(context.messages);
1549
1831
  let promptText = extractUserPrompt(context.messages) ?? "";
1550
1832
 
1833
+ // A turn continuing past a discarded query ends at its tool result, not at a
1834
+ // prompt, so say what happened rather than falling into the empty-prompt
1835
+ // recovery below — that one is for a shape we do not expect, and this is one
1836
+ // we do. The rebuilt session already ends with the tool result, placed after
1837
+ // the tool call it answers.
1838
+ if (rewrittenUnderQuery && !promptText && !promptBlocks) {
1839
+ promptText = CONTINUE_AFTER_REWRITE_PROMPT;
1840
+ debug(`provider: continuing the turn after a rewritten history, ${context.messages.length} msgs rebuilt`);
1841
+ }
1842
+
1551
1843
  // Guard: empty prompt means the last context message isn't a user message.
1552
1844
  // This should never happen with per-query state — dump diagnostics if it does.
1553
1845
  if (!promptText && !promptBlocks) {
@@ -1557,7 +1849,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1557
1849
  isReentrant,
1558
1850
  activeQueryContexts: activeQueryContexts.size,
1559
1851
  activeQueryExists: queryCtx.activeQuery !== null,
1560
- sharedSession: sharedSession ? { sessionId: sharedSession.sessionId.slice(0, 8), cursor: sharedSession.cursor } : null,
1852
+ sharedSession: sessionStateFor(piSessionId) ? { sessionId: sessionStateFor(piSessionId)!.sessionId.slice(0, 8), cursor: sessionStateFor(piSessionId)!.cursor } : (sessionStateFor(null) ? { sessionId: sessionStateFor(null)!.sessionId.slice(0, 8), cursor: sessionStateFor(null)!.cursor } : null),
1561
1853
  messageRoles: context.messages.map((m, i) => `[${i}]${m.role}`).join(" "),
1562
1854
  });
1563
1855
  // Recover: use a continuation prompt so the SDK doesn't send an empty text block
@@ -1582,20 +1874,25 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1582
1874
  // settingSources is left at CC's default, which loads all sources.
1583
1875
  const strictMcpConfigEnabled = providerSettings.strictMcpConfig !== false;
1584
1876
 
1585
- // Prefer the model's own thinkingLevelMap when present (pi-ai 0.72+ ships
1586
- // per-model overrides — e.g. opus-4-7 wants xhigh→xhigh, not xhigh→max).
1587
- // Fall back to our generic table for older pi-ai or unmapped levels.
1877
+ // Prefer the model's own thinkingLevelMap (per-model overrides — e.g. a map can
1878
+ // route xhigh→xhigh where the generic table maps xhigh→max). pi-ai's catalog
1879
+ // ships a map for most Claude models; the table below covers models without
1880
+ // one, and the levels a map leaves unnamed. A null entry means the level is
1881
+ // unsupported on that model: no effort argument is sent, so Claude Code's own
1882
+ // default applies rather than the generic table's value. Map values are
1883
+ // provider-generic strings, so a map value is trusted only when it names a
1884
+ // level CC accepts.
1885
+ const mapped = options?.reasoning ? model.thinkingLevelMap?.[options.reasoning] : undefined;
1588
1886
  const effort = options?.reasoning
1589
- ? ((model as any).thinkingLevelMap?.[options.reasoning] as EffortLevel | undefined)
1590
- ?? REASONING_TO_EFFORT[options.reasoning]
1887
+ ? mapped === undefined
1888
+ ? REASONING_TO_EFFORT[options.reasoning]
1889
+ : VALID_EFFORTS.has(mapped as EffortLevel) ? mapped as EffortLevel : undefined
1591
1890
  : undefined;
1592
1891
 
1593
1892
  const extraArgs: Record<string, string | null> = { model: cliModel };
1594
1893
  if (strictMcpConfigEnabled) extraArgs["strict-mcp-config"] = null;
1595
1894
  // Opus 4.7 defaults thinking.display to "omitted" (empty thinking text in stream).
1596
1895
  // Force summarized so thinking_delta events arrive. See anthropics/claude-agent-sdk-python#830.
1597
- // Fable 5 / 5.1 always think and also default to omitted, even when we pass no effort
1598
- // (CC then uses the model default, high).
1599
1896
  if (effort || adaptiveThinkingAlwaysOn(model.id)) extraArgs["thinking-display"] = "summarized";
1600
1897
 
1601
1898
  // Suppress claude.ai cloud MCP servers (Figma/Canva/etc. auto-discovered via OAuth
@@ -1621,7 +1918,6 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1621
1918
  // hit on every transition. Cost here is nil: the setting also strips
1622
1919
  // CC's git-workflow guidance from its Bash tool prompt, but the provider
1623
1920
  // path runs CC with `tools: []`, so those definitions never ship.
1624
- // AskClaude keeps CC's native tools and its guidance — unaffected.
1625
1921
  settings: {
1626
1922
  ...claudeCodeSettings(providerSettings),
1627
1923
  claudeMdExcludes: CLAUDE_MD_EXCLUDES,
@@ -1645,15 +1941,19 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1645
1941
  `ctxFiles=${promptCapture?.contextFiles.length ?? 0} strictMcp=${strictMcpConfigEnabled}`,
1646
1942
  `prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`);
1647
1943
 
1648
- // Resolve Pi's Anthropic credential before every fresh child. OAuth refresh is
1649
- // asynchronous, while pi's provider API must return its stream synchronously,
1650
- // so query startup runs in the background and forwards into the claimed stream.
1944
+ // Resolve Pi's refreshed Anthropic auth asynchronously without changing the
1945
+ // synchronous provider API. Claim a pending handle so nested calls stay reentrant.
1651
1946
  let wasAborted = false;
1652
1947
  let sdkQuery: ReturnType<typeof query> | null = null;
1653
1948
  const authPending = { kind: "claude-auth-pending" };
1654
1949
  queryCtx.activeQuery = authPending;
1950
+
1951
+ // Capture context for abort handling
1655
1952
  const abortCtx = queryCtx;
1953
+
1656
1954
  const requestAbort = () => {
1955
+ // interrupt() asks the CLI to stop gracefully; close() kills it immediately.
1956
+ // Both are needed — interrupt alone lets the current API call finish.
1657
1957
  void sdkQuery?.interrupt().catch(() => {});
1658
1958
  try { sdkQuery?.close(); } catch {}
1659
1959
  };
@@ -1667,88 +1967,115 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1667
1967
  else options.signal.addEventListener("abort", onAbort, { once: true });
1668
1968
  }
1669
1969
 
1970
+ // Background consumer — runs until query ends.
1670
1971
  void (async () => {
1671
1972
  queryOptions.env = await resolveClaudeChildEnv(piModelRegistry);
1672
1973
  if (wasAborted || options?.signal?.aborted) throw new Error("Operation aborted");
1673
-
1674
- const startedQuery = query({ prompt: promptStream.stream, options: queryOptions });
1974
+ const startedQuery = queryImpl({ prompt: promptStream.stream, options: queryOptions });
1675
1975
  sdkQuery = startedQuery;
1676
1976
  queryCtx.activeQuery = startedQuery;
1677
1977
  activeQueryContexts.add(queryCtx);
1678
- // query() may synchronously trigger an abort before sdkQuery is assigned.
1679
- // Re-check now so the just-created child cannot escape requestAbort().
1680
1978
  if (wasAborted || options?.signal?.aborted) {
1681
1979
  requestAbort();
1682
1980
  throw new Error("Operation aborted");
1683
1981
  }
1684
-
1685
1982
  const { capturedSessionId } = await consumeQuery(startedQuery, customToolNameToPi, model, () => wasAborted, queryCtx);
1686
1983
  debug(`provider: consumeQuery completed, stopReason=${queryCtx.turnOutput?.stopReason}, error=${queryCtx.turnOutput?.errorMessage}, aborted=${wasAborted}`);
1687
1984
 
1985
+ // Discarded out from under: the query continuing the turn owns the context,
1986
+ // the session and the stream. Capturing this one's session id here would put
1987
+ // Claude Code back on the conversation it was discarded for.
1988
+ if (abandonedQueries.has(sdkQuery)) {
1989
+ debug("provider: discarded query completed, leaving session and stream to its replacement");
1990
+ return;
1991
+ }
1992
+
1993
+ // --- Abort detection in normal completion path ---
1688
1994
  if (wasAborted || options?.signal?.aborted) {
1689
- if (sharedSession) sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
1995
+ // The killed subprocess may flush a late record into this session's
1996
+ // JSONL — its own mirror's next sync must rebuild and rotate.
1997
+ const state = sessionStateFor(queryCtx.piSessionId);
1998
+ if (state) setSessionStateFor(queryCtx.piSessionId, { ...state, needsRebuild: true, forceRotate: true });
1690
1999
  debug(`provider: abort detected, marked sharedSession needsRebuild + forceRotate`);
1691
2000
  if (queryCtx.turnOutput) {
1692
2001
  queryCtx.turnOutput.stopReason = "aborted";
1693
2002
  queryCtx.turnOutput.errorMessage = "Operation aborted";
1694
2003
  }
1695
- const currentStream = queryCtx.currentPiStream;
1696
- currentStream?.push({ type: "error", reason: "aborted", error: queryCtx.turnOutput! });
1697
- markStreamComplete(currentStream);
1698
- currentStream?.end();
2004
+ const stream = queryCtx.currentPiStream;
2005
+ stream?.push({ type: "error", reason: "aborted", error: queryCtx.turnOutput! });
2006
+ markStreamComplete(stream);
2007
+ stream?.end();
1699
2008
  queryCtx.currentPiStream = null;
1700
2009
  return;
1701
2010
  }
1702
2011
 
1703
- const sessionId = capturedSessionId ?? sharedSession?.sessionId;
2012
+ // --- Capture session ID ---
2013
+ // This query's own mirror — a reentrant subagent completing does not
2014
+ // reassign the parent's conversation to the child's CC file.
1704
2015
  if (syncResult.preserveSharedSession) {
1705
- if (capturedSessionId && capturedSessionId !== sharedSession?.sessionId) {
2016
+ const state = sessionStateFor(queryCtx.piSessionId);
2017
+ if (capturedSessionId && capturedSessionId !== state?.sessionId) {
1706
2018
  deleteSession(capturedSessionId, cwd, process.env.CLAUDE_CONFIG_DIR);
1707
2019
  debug(`provider: query done, deleted ephemeral session ${capturedSessionId.slice(0, 8)} to preserve shared session`);
1708
2020
  }
1709
2021
  debug(`provider: query done, ignoring captured session ${capturedSessionId?.slice(0, 8) ?? "none"} to preserve shared session`);
1710
- } else if (sessionId) {
1711
- const cursor = Math.max(context.messages.length, queryCtx.latestCursor, sharedSession?.cursor ?? 0);
1712
- debug(`provider: query done, session=${sessionId.slice(0, 8)}, cursor=${cursor}`);
1713
- sharedSession = { sessionId, cursor, cwd };
2022
+ } else {
2023
+ const state = sessionStateFor(queryCtx.piSessionId);
2024
+ const sessionId = capturedSessionId ?? state?.sessionId;
2025
+ if (sessionId) {
2026
+ const cursor = Math.max(context.messages.length, queryCtx.latestCursor, state?.cursor ?? 0);
2027
+ debug(`provider: query done, session=${sessionId.slice(0, 8)}, cursor=${cursor}`);
2028
+ // A missed steer may precede the first mirror or arrive while this
2029
+ // query is still able to complete. Preserve both rebuild signals.
2030
+ setSessionStateFor(queryCtx.piSessionId, { ...state, sessionId, cursor, cwd, piSessionId: queryCtx.piSessionId ?? undefined, needsRebuild: queryCtx.missedSteer || state?.needsRebuild });
2031
+ }
1714
2032
  }
1715
2033
 
1716
- if (queryCtx.activeQuery === startedQuery) {
1717
- activeQueryContexts.delete(queryCtx);
1718
- if (!isReentrant) debug("provider: clearing activeQuery before final stream completion");
2034
+ if (!isReentrant && queryCtx.activeQuery === sdkQuery) {
2035
+ debug("provider: clearing activeQuery before final stream completion");
1719
2036
  queryCtx.activeQuery = null;
1720
2037
  }
1721
2038
  finalizeCurrentStream(queryCtx, queryCtx.turnOutput?.stopReason);
1722
2039
  })()
1723
2040
  .catch((error) => {
1724
- debug(`provider: query error, model=${cliModel}, aborted=${Boolean(wasAborted || options?.signal?.aborted)}, error=`, error);
1725
- if ((wasAborted || options?.signal?.aborted) && sharedSession) {
1726
- sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
2041
+ debug(`provider: query error, model=${cliModel}, aborted=${Boolean(options?.signal?.aborted)}, error=`, error);
2042
+ if (sdkQuery && abandonedQueries.has(sdkQuery)) {
2043
+ debug("provider: discarded query ended in error, leaving session and stream to its replacement");
2044
+ return;
2045
+ }
2046
+ if ((wasAborted || options?.signal?.aborted)) {
2047
+ const state = sessionStateFor(queryCtx.piSessionId);
2048
+ if (state) setSessionStateFor(queryCtx.piSessionId, { ...state, needsRebuild: true, forceRotate: true });
1727
2049
  } else if (sdkQuery) {
1728
- // Once Claude Code starts, a failed turn can leave its transcript
1729
- // incomplete. Auth-resolution failures happen before that and must not
1730
- // discard an otherwise resumable shared session.
1731
- sharedSession = null;
2050
+ // Auth failures precede the subprocess and must preserve a resumable mirror.
2051
+ // Drop this session's mirror: its conversation is in an unknown
2052
+ // state after the error. Other sessions' mirrors stay — one
2053
+ // session's failure says nothing about another's conversation.
2054
+ setSessionStateFor(queryCtx.piSessionId, null);
1732
2055
  }
1733
2056
  promptStream.fail(error instanceof Error ? error : new Error(String(error)));
1734
2057
  if (queryCtx.turnOutput) {
1735
- queryCtx.turnOutput.stopReason = wasAborted || options?.signal?.aborted ? "aborted" : "error";
2058
+ queryCtx.turnOutput.stopReason = options?.signal?.aborted ? "aborted" : "error";
2059
+ // The SDK drops its copy of the result text if any message follows the error
2060
+ // result, so prefer the cause consumeQuery recorded off the result itself.
1736
2061
  queryCtx.turnOutput.errorMessage ??= error instanceof Error ? error.message : String(error);
1737
2062
  }
1738
- if (queryCtx.activeQuery === authPending || (sdkQuery && queryCtx.activeQuery === sdkQuery)) {
2063
+ if (!isReentrant && queryCtx.activeQuery === sdkQuery) {
1739
2064
  queryCtx.releasePendingToolCalls("Query ended");
1740
- activeQueryContexts.delete(queryCtx);
1741
- if (!isReentrant) debug("provider: clearing activeQuery before error stream completion");
2065
+ debug("provider: clearing activeQuery before error stream completion");
1742
2066
  queryCtx.activeQuery = null;
1743
2067
  }
1744
- const currentStream = queryCtx.currentPiStream;
1745
- currentStream?.push({ type: "error", reason: (queryCtx.turnOutput?.stopReason ?? "error") as "aborted" | "error", error: queryCtx.turnOutput! });
1746
- markStreamComplete(currentStream);
1747
- currentStream?.end();
2068
+ const stream = queryCtx.currentPiStream;
2069
+ stream?.push({ type: "error", reason: (queryCtx.turnOutput?.stopReason ?? "error") as "aborted" | "error", error: queryCtx.turnOutput! });
2070
+ markStreamComplete(stream);
2071
+ stream?.end();
1748
2072
  queryCtx.currentPiStream = null;
1749
2073
  })
1750
2074
  .finally(() => {
1751
2075
  if (options?.signal) options.signal.removeEventListener("abort", onAbort);
2076
+ // Settle any ack still parked in the generator — the CLI is gone, so
2077
+ // nothing will resume it. Clear the handle only if a later query
2078
+ // hasn't already claimed the shared context.
1752
2079
  promptStream.fail(new Error("query ended"));
1753
2080
  if (queryCtx.promptStream === promptStream) queryCtx.promptStream = null;
1754
2081
  // A later query claiming this context sets activeQuery to its own handle;
@@ -1757,8 +2084,6 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1757
2084
  // path, leaving the top-level context in the routing set forever — where a
1758
2085
  // later orphaned tool result matches its stale turnToolCallIds and takes
1759
2086
  // the delivery branch, returning a stream nothing ends.
1760
- // authPending covers the window before Claude Code starts, when sdkQuery
1761
- // is still null.
1762
2087
  if (queryCtx.activeQuery === authPending || queryCtx.activeQuery === sdkQuery || queryCtx.activeQuery === null) {
1763
2088
  queryCtx.releasePendingToolCalls("Query ended");
1764
2089
  queryCtx.activeQuery = null;
@@ -1770,10 +2095,8 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1770
2095
  return stream;
1771
2096
  }
1772
2097
 
1773
-
1774
2098
  // --- Extension registration ---
1775
2099
 
1776
-
1777
2100
  export default function (pi: ExtensionAPI) {
1778
2101
  // Disable non-essential Claude Code traffic (update checks, MCP registry, telemetry)
1779
2102
  process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
@@ -1782,11 +2105,20 @@ export default function (pi: ExtensionAPI) {
1782
2105
  debug("loadConfig:", JSON.stringify(config));
1783
2106
  providerSettings = config.provider ?? {};
1784
2107
  // We need these settings to know if we're eligible for 1M context on certain models
2108
+ // Validate at the boundary: a non-array here would throw inside every
2109
+ // claudeCodeModelId call and brick the extension at activation.
2110
+ const forceTwoHundredK = Array.isArray(providerSettings.forceTwoHundredK)
2111
+ ? providerSettings.forceTwoHundredK.filter((id): id is string => typeof id === "string")
2112
+ : undefined;
1785
2113
  longContextSettings = {
1786
2114
  plan: providerSettings.plan ?? "max",
1787
2115
  longContextExtraUsage: providerSettings.longContextExtraUsage ?? false,
2116
+ forceTwoHundredK,
1788
2117
  };
1789
2118
  const registeredModels = applyLongContext(MODELS, longContextSettings);
2119
+ if (registeredModels.length === 0) {
2120
+ console.error("claude-bridge: no models available from pi-ai's anthropic catalog — update @earendil-works/pi-ai (requires >=0.86.1)");
2121
+ }
1790
2122
 
1791
2123
  if (!config.startupNoticeShown) {
1792
2124
  if (config.provider?.plan === undefined) pendingNotices.push('Assuming a Max plan. On Pro, set provider.plan to "pro" so Opus 4.6 stays at 200K context.');
@@ -1794,8 +2126,12 @@ export default function (pi: ExtensionAPI) {
1794
2126
 
1795
2127
  // Reset shared session on pi session lifecycle events
1796
2128
  const clearSession = (event: string) => {
1797
- debug(`${event}: clearing session ${sharedSession?.sessionId?.slice(0, 8) ?? "none"}`);
1798
- sharedSession = null;
2129
+ debug(`${event}: clearing ${sharedSessions.size} shared session${sharedSessions.size === 1 ? "" : "s"}`);
2130
+ // Whole map: children never emit session_shutdown (only runtime teardown
2131
+ // and /reload do), so there is no per-entry removal to do here — the
2132
+ // top-level transition takes every mirror with it.
2133
+ sharedSessions.clear();
2134
+ historyRewrittenBySession.clear();
1799
2135
 
1800
2136
  // Clear the global streamSimple if this instance registered it.
1801
2137
  // This allows /reload to work — the old instance clears the flag so
@@ -1817,15 +2153,57 @@ export default function (pi: ExtensionAPI) {
1817
2153
  // `--system-prompt` replaces pi's default rather than adding to it, but Claude
1818
2154
  // Code's preset carries its own tool and permission guidance that the bridge
1819
2155
  // still depends on, so both flags are forwarded as an append.
1820
- pi.on("before_agent_start", (event) => {
1821
- const options = event.systemPromptOptions;
2156
+ //
2157
+ // The options (custom/append/contextFiles/skills) are pi config, stable across a
2158
+ // turn; only the auto-generated tool list in the rendered prompt varies. Stash them
2159
+ // at before_agent_start so the agent_start recording below can reuse them.
2160
+ type RecordOptions = Parameters<typeof recordSystemPrompt>[2];
2161
+ let lastSystemPromptOptions: RecordOptions | undefined;
2162
+ function recordSystemPrompt(source: string, systemPrompt: string | undefined, options: {
2163
+ customPrompt?: string;
2164
+ appendSystemPrompt?: string;
2165
+ contextFiles?: { path: string; content: string }[];
2166
+ skills?: Parameters<typeof promptCaptures.record>[1]["skills"];
2167
+ selectedTools?: string[];
2168
+ } | undefined) {
2169
+ if (!systemPrompt) return;
1822
2170
  const hasRead = !options?.selectedTools || options.selectedTools.includes("read");
1823
- promptCaptures.record(event.systemPrompt, {
2171
+ promptCaptures.record(systemPrompt, {
1824
2172
  custom: options?.customPrompt,
1825
2173
  append: options?.appendSystemPrompt,
1826
2174
  contextFiles: options?.contextFiles ?? [],
1827
2175
  skills: hasRead ? options?.skills ?? [] : [],
1828
- });
2176
+ }, source);
2177
+ }
2178
+ pi.on("before_agent_start", (event) => {
2179
+ lastSystemPromptOptions = event.systemPromptOptions;
2180
+ recordSystemPrompt("before_agent_start", event.systemPrompt, event.systemPromptOptions);
2181
+ });
2182
+ // The prompt the provider actually queries with is the fully-widened one: MCP tool
2183
+ // descriptions merge into the system prompt only after their servers connect, which
2184
+ // is after before_agent_start. ctx.getSystemPrompt() returns that widened prompt by
2185
+ // agent_start (verified: before_agent_start=10,988 chars vs agent_start/query=23,479).
2186
+ // A subagent embeds the widened parent prompt verbatim (pi-subagents reads
2187
+ // ctx.getSystemPrompt() at dispatch), so unless the widened prompt is a capture key
2188
+ // too, the child's turn resolves against nothing, falls to a verbatim side request,
2189
+ // and ships pi's harness — tripping the server's third-party plan-eligibility check
2190
+ // ("out of extra usage"). Recording it here, before the query, restores the match.
2191
+ //
2192
+ // agent_start also captures a handler-returned forceSystemPrompt, which
2193
+ // buildSystemPrompt renders verbatim.
2194
+ pi.on("agent_start", (_event, ctx) => {
2195
+ recordSystemPrompt("agent_start", ctx.getSystemPrompt(), lastSystemPromptOptions);
2196
+ });
2197
+
2198
+ // Mid-run re-renders: turn_start fires before every turn (first turn included)
2199
+ // after the turn's prompt is final: prepareNextTurnWithContext has
2200
+ // re-rendered the options (pi's section-based prompt) and any mid-run
2201
+ // setActiveToolsByName rebuild has already landed. Re-keying at each boundary
2202
+ // the prompt can change at keeps exact-match alive mid-run. The stashed options can
2203
+ // lag a mid-run tool-loadout change, which skews the hasRead skills filter until the
2204
+ // next before_agent_start — accepted: a stale skills list beats failing the turn.
2205
+ pi.on("turn_start", (_event, ctx) => {
2206
+ recordSystemPrompt("turn_start", ctx.getSystemPrompt(), lastSystemPromptOptions);
1829
2207
  });
1830
2208
  pi.on("session_shutdown", () => {
1831
2209
  reportLeaks("session_shutdown");
@@ -1838,40 +2216,71 @@ export default function (pi: ExtensionAPI) {
1838
2216
  // slice(cursor) === [] (or skip entries) and keep --resume'ing a CC
1839
2217
  // session that no longer matches pi's history. /compact in particular
1840
2218
  // triggers CC's autocompact-thrashing guard (issue #8). Force the next
1841
- // call down the REBUILD path so CC sees the current history.
1842
- const markRebuild = (event: string) => {
1843
- if (sharedSession) {
1844
- debug(`${event}: marking needsRebuild on session ${sharedSession.sessionId.slice(0, 8)}`);
1845
- sharedSession = { ...sharedSession, needsRebuild: true };
1846
- }
1847
- };
1848
- pi.on("session_compact", (event) => markRebuild(`session_compact:${event.reason}:willRetry=${event.willRetry}`));
1849
- pi.on("session_tree", () => markRebuild("session_tree"));
2219
+ // call down the REBUILD path so CC sees the current history — and, when a
2220
+ // query is parked at a tool boundary while this fires, discard that query
2221
+ // instead of resuming it (markRebuild, discardRewrittenQuery).
2222
+ //
2223
+ // Attributed to the compacting session (ctx.sessionManager belongs to the
2224
+ // session whose runner fired this), so a subagent compacting while its
2225
+ // parent sits parked on the Agent tool result discards nothing — the parent's
2226
+ // query is live and its conversation untouched. Registered by every instance
2227
+ // of this module; instances sponsoring stale marks forward them to the
2228
+ // serving instance via sponsorMarkRebuildForSession.
2229
+ pi.on("session_compact", (event, ctx) =>
2230
+ sponsorMarkRebuildForSession(ctx.sessionManager.getSessionId(), `session_compact:${event.reason}:willRetry=${event.willRetry}`));
2231
+ pi.on("session_tree", (_event, ctx) => sponsorMarkRebuildForSession(ctx.sessionManager.getSessionId(), "session_tree"));
1850
2232
 
1851
2233
  // --- Provider ---
1852
2234
  //
1853
- // Guard against re-registration when the module is loaded multiple times
1854
- // (e.g., when spawning subagents). The shared ModelRegistry would otherwise
1855
- // overwrite the parent's streamSimple, breaking tool result delivery.
1856
- // See ACTIVE_STREAM_SIMPLE_KEY for the full mechanism.
2235
+ // Registration policy across module instances (a subagent session can load
2236
+ // this module fresh): the FIRST instance registers unconditionally at load,
2237
+ // which is what puts claude-bridge models in the picker before any session
2238
+ // starts. Later instances decide at session_start, when ctx.modelRegistry
2239
+ // reveals who owns this session's registry:
2240
+ //
2241
+ // - Registry already has the provider (host passes the parent's registry down,
2242
+ // e.g. pi-subagents >=0.14.3): skip. Re-registering would overwrite the
2243
+ // parent's pinned streamSimple with this instance's fresh — empty-state —
2244
+ // stream fn, and the parent's next tool-result delivery would route into it.
2245
+ // - Registry lacks the provider (host gives the child its own, e.g. older
2246
+ // pi-subagents forks): register, or every claude-bridge/* dispatch in the
2247
+ // child fails with "Model not found" (#91). Even loading the bridge via the
2248
+ // agent's `extensions:` frontmatter didn't help there — the module loaded,
2249
+ // hit the old skip-guard, and the child's registry stayed empty.
2250
+ //
2251
+ // A per-instance stream fn registered into a per-instance registry is
2252
+ // self-consistent: that session's traffic flows through this module state,
2253
+ // which starts clean and serves only that session.
2254
+ //
2255
+ // On session_shutdown (including /reload), clearSession() resets
2256
+ // ACTIVE_STREAM_SIMPLE_KEY so a freshly loaded module can register as first
2257
+ // again.
1857
2258
 
1858
2259
  const g = globalThis as Record<symbol, any>;
2260
+ const providerConfig = {
2261
+ baseUrl: "claude-bridge",
2262
+ apiKey: "not-used",
2263
+ api: "claude-bridge",
2264
+ models: registeredModels,
2265
+ // Cast: the Provider interface passes a TranscriptContext; the bridge takes plain
2266
+ // Context models (toBridgeContext normalizes at the stream entry points).
2267
+ streamSimple: streamClaudeAgentSdk as any,
2268
+ };
1859
2269
  if (!g[ACTIVE_STREAM_SIMPLE_KEY]) {
1860
2270
  // First instance: store our streamSimple and register.
1861
2271
  g[ACTIVE_STREAM_SIMPLE_KEY] = streamClaudeAgentSdk;
1862
- pi.registerProvider(PROVIDER_ID, {
1863
- baseUrl: "claude-bridge",
1864
- apiKey: "not-used",
1865
- api: "claude-bridge",
1866
- models: registeredModels,
1867
- // Cast: pi-ai AssistantMessageEventStream diamond dep between pi-coding-agent and pi-agent-core
1868
- streamSimple: streamClaudeAgentSdk as any,
1869
- });
2272
+ pi.registerProvider(PROVIDER_ID, providerConfig);
1870
2273
  } else {
1871
- // Subsequent instance (subagent session): skip registration entirely.
1872
- // The subagent already has access to claude-bridge models via the shared
1873
- // ModelRegistry from the parent's registration. Calls to those models
1874
- // route through the parent's streamSimple via reentrant QueryContexts.
1875
- debug(`provider: skipping re-registration, parent instance active (module=${moduleInstanceId})`);
2274
+ // Later instance: register only if this session's registry lacks the provider.
2275
+ debug(`provider: deferring registration decision to session_start (module=${moduleInstanceId})`);
2276
+ pi.on("session_start", (_event, ctx) => {
2277
+ if (ctx.modelRegistry.getProvider(PROVIDER_ID)) {
2278
+ debug(`provider: registry already has ${PROVIDER_ID}, skipping registration (module=${moduleInstanceId})`);
2279
+ return;
2280
+ }
2281
+ debug(`provider: registry lacks ${PROVIDER_ID}, registering (module=${moduleInstanceId})`);
2282
+ pi.registerProvider(PROVIDER_ID, providerConfig);
2283
+ });
1876
2284
  }
2285
+
1877
2286
  }