@vanillagreen/pi-claude-bridge 1.9.0 → 3.2.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,21 +1,19 @@
1
1
  import { type AssistantMessage, type AssistantMessageEventStream, type Context, type Model, type SimpleStreamOptions, type Tool } from "@earendil-works/pi-ai";
2
2
  import * as piAi from "@earendil-works/pi-ai";
3
- import { type ExtensionAPI, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
4
- import { createSdkMcpServer, query, type EffortLevel, type SDKMessage, type SDKUserMessage, type SettingSource } from "@anthropic-ai/claude-agent-sdk";
3
+ import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { createSdkMcpServer, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
5
5
  import type { Base64ImageSource, ContentBlockParam, MessageParam } from "@anthropic-ai/sdk/resources";
6
6
  import { PROVIDER_ID, messageContentToText } from "./convert.js";
7
- import { buildModels, fallbackModelForPrimaryModel, modelDisplayName } from "./models.js";
8
- import { MCP_SERVER_NAME, MCP_TOOL_PREFIX, extractSkillsBlock } from "./skills.js";
7
+ import { buildModels, modelDisplayName } from "./models.js";
8
+ import { MCP_SERVER_NAME, MCP_TOOL_PREFIX } from "./skills.js";
9
9
  import { extractAllToolResults as _extractAllToolResults, type McpResult } from "./extract-tool-results.js";
10
- import { QueryContext, ctx, drainPendingToolCalls, stackDepth, pushContext, popContext, toolCallDrainCause } from "./query-state.js";
11
- import { loadConfig, normalizeEffortLevel, recordProjectTrust, type Config } from "./config.js";
12
- import { decideRegistration, hasClaudeCredentials } from "./auth-presence.js";
13
- import { extractAgentsAppend } from "./agents-md.js";
14
- import { buildPromptContextAppend } from "./prompt-context.js";
10
+ import { QueryContext, ctx, deleteQueryLane, drainPendingToolCalls, drainStrandedToolCalls, popContext, stackDepth, pushContext, summarizeDroppedUserMessages, takeQueuedOrParkedResult, toolCallDrainCause, type DeferredUserMessage } from "./query-state.js";
11
+ import { teardownQuery } from "./query-teardown.js";
12
+ import { loadConfig, recordProjectTrust, registerExternalConfigResolver } from "./config.js";
13
+ import { hasClaudeCredentials } from "./auth-presence.js";
14
+ import { NATIVE_PROVIDER_UNSUPPORTED_MESSAGE, buildNativeProvider, supportsNativeProvider } from "./native-provider.js";
15
15
  import { jsonSchemaToZodShape } from "./typebox-to-zod.js";
16
- import { readFileSync as nodeReadFileSync } from "node:fs";
17
16
  import { resolveGetModels } from "./pi-ai-compat.js";
18
- import { listAccountConnectors, resolveClaudeOAuth } from "./connector-inventory.js";
19
17
  // Re-exported from the extension entry point ON PURPOSE. Consuming apps
20
18
  // regenerate their vendored package.json with a CLOSED exports map
21
19
  // ({".": "./bundle/index.js"}), which makes Node reject BOTH a subpath import
@@ -25,6 +23,8 @@ import { listAccountConnectors, resolveClaudeOAuth } from "./connector-inventory
25
23
  // path their existing manifest already allows, and incidentally keeps esbuild
26
24
  // from tree-shaking helpers index.ts never calls itself.
27
25
  export {
26
+ connectorProxyUrl,
27
+ connectorServerName,
28
28
  connectorServerNamespace,
29
29
  connectorsListUrl,
30
30
  credentialCandidatePaths,
@@ -34,27 +34,70 @@ export {
34
34
  type ConnectorEntry,
35
35
  type ConnectorInventory,
36
36
  } from "./connector-inventory.js";
37
+ export { connectorCachePath, connectorCacheScopeKey, readCachedConnectors, scopeKeyFor, writeCachedConnectors } from "./connector-cache.js";
38
+ export { connectorServersSnapshot, primeConnectorServers } from "./connector-runtime.js";
37
39
  import { debug, diagDump, makeCliDebugOptions, moduleInstanceId } from "./debug.js";
38
- import { preflightClaudeExecutable, resolveClaudeExecutable, spawnClaudeCodeWithDiagnostics } from "./claude-executable.js";
39
- import { argKeys, extensionApi, piUI, reportToolResultMismatch, safeNotify, safeToolCallSummary, setExtensionApi, setPiUI, setSharedSession, sharedSession } from "./bridge-state.js";
40
- import { connectorQueryOptions, connectorWriteModeFor, connectorsEnabledFor } from "./connectors.js";
41
- import { restoreSharedSessionFromPi, schedulePersistSharedSession, syncSharedSession } from "./session-persistence.js";
40
+ import { preflightClaudeExecutable, resolveClaudeExecutable } from "./claude-executable.js";
41
+ import { appendIntegrityEntry, argKeys, deleteSharedSessionLane, extensionApi, getSharedSession, markSessionForRebuild, recordStartedLane, reportToolResultMismatch, safeNotify, safeToolCallSummary, setExtensionApi, setPiUI, setSharedSession, takeStartedLane, type SessionState } from "./bridge-state.js";
42
+ import { connectorsEnabledFor, isChildExecutedTool } from "./connectors.js";
43
+ import { primeConnectorServers } from "./connector-runtime.js";
44
+ import { cancelScheduledSessionPersistence, conversationFingerprint, restoreSharedSessionFromPi, schedulePersistSharedSession, syncSharedSession } from "./session-persistence.js";
42
45
  import { STREAM_IDLE_BACKOFF_HINT_MS, activeStreamIdleWatchdogs, buildStreamIdleTimeoutErrorMessage, createStreamIdleWatchdog, formatDurationShort, streamIdleTimeoutMsFromEnv } from "./stream-idle-watchdog.js";
43
- import { RATE_LIMIT_AUTO_RESUME_EVENT, RATE_LIMIT_TOKEN, formatAllowedRateLimitWarning, formatResetTimestamp, isExtraUsageRequiredMessage, uniqueNonEmptyLines } from "./rate-limit.js";
46
+ import { RATE_LIMIT_TOKEN, formatResetTimestamp } from "./rate-limit.js";
44
47
  import { mapToolArgs } from "./tool-mapping.js";
45
- import { ensureTurnStarted, finalizeCurrentStream, parsePartialJson, processAssistantMessage, processStreamEvent, updateTurnOutputModel } from "./assistant-stream.js";
48
+ import { finalizeCurrentStream, finalizeToolUseTurnFromMcpInvocation, scheduleToolUseTurnEnd, updateTurnOutputModel } from "./assistant-stream.js";
49
+ import {
50
+ accountSessionScope,
51
+ classifyClaudeFailure,
52
+ CLAUDE_BRIDGE_ACCOUNT_HOST_SYMBOL,
53
+ rateLimitResetFromInfo,
54
+ rateLimitResetMs,
55
+ rateLimitTypeFromInfo,
56
+ resolveClaudeAccountRouter,
57
+ RetryEventBuffer,
58
+ safeRouterCall,
59
+ type ClaudeAccountRoute,
60
+ } from "./account-router.js";
61
+ import { BRIDGE_ACCOUNT_HOST } from "./account-host.js";
62
+ import { registerBridgeCommands } from "./bridge-commands.js";
63
+ import { consumeQuery, emitRateLimitEvent, type ClaudeAttemptFailure } from "./consume-query.js";
64
+ import { buildClaudeQueryOptions } from "./query-options.js";
65
+ import { sdkQueryFactory } from "./sdk-query.js";
66
+ import { currentRequestLaneId, runInRequestLane } from "./request-lane.js";
46
67
 
47
68
  // Re-exports: the module decomposition must not change the bundle entry's
48
69
  // public surface — unit tests and downstream consumers import these from
49
70
  // bundle/index.js.
71
+ export { probeClaudeAccountProfile } from "./account-host.js";
72
+ export { __testSetSdkQueryFactory } from "./sdk-query.js";
73
+ export { resolveConfiguredEffort } from "./query-options.js";
50
74
  export { classifyClaudeExecutableBytes, preflightClaudeExecutable, resolveClaudeExecutable, spawnClaudeCodeWithDiagnostics, wrapClaudeSpawnErrorForSdk, type ClaudeExecutableFileType, type ClaudeExecutablePreflightResult } from "./claude-executable.js";
51
- export { __testGetBridgeIntegrityState, __testSetBridgeIntegrityState, reportToolResultMismatch } from "./bridge-state.js";
52
- export { CLAUDE_AI_CONNECTOR_TOOL_PATTERNS, CLAUDE_BRIDGE_TOOL_ISOLATION, CONNECTOR_DISCOVERY_TOOLS, CONNECTOR_WRITE_TOOLS, DISALLOWED_BUILTIN_TOOLS, connectorQueryOptions, connectorWriteDenyHook, connectorWriteModeFor, connectorWriteModeFromEnv, connectorsEnabledFor, connectorsEnabledFromEnv, isConnectorWriteTool, toolIsolationForQuery } from "./connectors.js";
53
- export { restoreSharedSessionFromPi, shouldRestorePersistedBridgeEntry } from "./session-persistence.js";
75
+ export { __testGetBridgeIntegrityState, __testSetBridgeIntegrityState, INTEGRITY_CUSTOM_TYPE, appendIntegrityEntry, reportToolResultMismatch } from "./bridge-state.js";
76
+ export { CONNECTOR_CALL_CUSTOM_TYPE, connectorResultByteSize, flushConnectorCallAudit, recordConnectorCallResult, setConnectorCallAuditSink, type ConnectorCallAuditData, type ConnectorCallAuditSink, type ConnectorCallOutcome } from "./connector-audit.js";
77
+ export { CLAUDE_AI_CONNECTOR_TOOL_PATTERNS, connectorMcpServers, connectorDeclarationsDisabled, CLAUDE_BRIDGE_TOOL_ISOLATION, CONNECTOR_DISCOVERY_TOOLS, CONNECTOR_WRITE_TOOLS, DISALLOWED_BUILTIN_TOOLS, connectorBuiltinAllowlistHook, connectorQueryOptions, connectorWriteDenyHook, connectorWriteModeFor, connectorWriteModeFromEnv, connectorsEnabledFor, connectorsEnabledFromEnv, denyAllToolsHook, isAllowlistedConnectorSessionTool, isChildExecutedTool, isChildInternalTool, isConnectorTool, isConnectorWriteTool, settingSourcesForQuery, toolIsolationForQuery } from "./connectors.js";
78
+ export { cancelScheduledSessionPersistence, conversationFingerprint, conversationFingerprintsMatch, planIncrementalPromptBatch, restoreSharedSessionFromPi, shouldRestorePersistedBridgeEntry } from "./session-persistence.js";
79
+ export { NATIVE_PROVIDER_UNSUPPORTED_MESSAGE, buildNativeProvider, claudeAuthSourceLabel, supportsNativeProvider } from "./native-provider.js";
54
80
  export { DEFAULT_STREAM_IDLE_TIMEOUT_MS, STREAM_IDLE_BACKOFF_HINT_MS, STREAM_IDLE_TIMEOUT_ENV, buildStreamIdleTimeoutErrorMessage, createStreamIdleWatchdog, streamIdleTimeoutMsFromEnv, type StreamIdleTimeoutInfo, type StreamIdleWatchdog, type StreamIdleWatchdogState } from "./stream-idle-watchdog.js";
55
- export { ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD, formatAllowedRateLimitWarning, formatResetTimestamp, isExtraUsageRequiredMessage, normalizeRateLimitUtilization, uniqueNonEmptyLines } from "./rate-limit.js";
81
+ export { ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD, formatAllowedRateLimitWarning, formatResetTimestamp, isUsageLimitMessage, normalizeRateLimitUtilization, resetTimestampMs, uniqueNonEmptyLines } from "./rate-limit.js";
56
82
  export { mapToolName } from "./tool-mapping.js";
57
- export { processAssistantMessage, processStreamEvent } from "./assistant-stream.js";
83
+ export { cancelScheduledToolUseEnd, endToolUseTurn, finalizeToolUseTurnFromMcpInvocation, noteChildExecutedToolResults, processAssistantMessage, processStreamEvent, reapStaleQueuedResults, scheduleToolUseTurnEnd } from "./assistant-stream.js";
84
+ export {
85
+ accountSessionScope,
86
+ claudeDirForProfile,
87
+ classifyClaudeFailure,
88
+ CLAUDE_ACCOUNT_ROUTER_SYMBOL,
89
+ CLAUDE_BRIDGE_ACCOUNT_HOST_SYMBOL,
90
+ commitsVisibleOutput,
91
+ rateLimitResetFromInfo,
92
+ rateLimitResetMs,
93
+ rateLimitTypeFromInfo,
94
+ RetryEventBuffer,
95
+ subscriberProfileEnv,
96
+ type ClaudeAccountFailureKind,
97
+ type ClaudeAccountRoute,
98
+ type ClaudeAccountRouterV1,
99
+ type ClaudeBridgeAccountHostV1,
100
+ } from "./account-router.js";
58
101
 
59
102
  // Compat (#2): use factory if available (pi-ai ≥0.66), else fall back to constructor (gsd-pi etc.)
60
103
  const _piAi = piAi as any;
@@ -89,86 +132,32 @@ const newAssistantMessageEventStream: () => AssistantMessageEventStream =
89
132
  //
90
133
  // Both are released on session_shutdown (incl. /reload) by releaseProviderTokens
91
134
  // so the next module load starts clean. See applyProviderRegistration for the
92
- // state machine and auth-presence.ts/decideRegistration for the pure decision.
135
+ // native (pi >=0.81) upsert flow.
93
136
  const PRIMARY_INSTANCE_KEY = Symbol.for("claude-bridge:primaryInstance");
94
137
  const ACTIVE_STREAM_SIMPLE_KEY = Symbol.for("claude-bridge:activeStreamSimple");
95
- const COMMANDS_REGISTERED_KEY = Symbol.for("claude-bridge:commandsRegistered");
138
+ // Deliberately NOT Symbol.for: rotation state rides options between the retry
139
+ // re-entry and the original call within ONE module instance only.
140
+ const ROTATION_STATE_KEY = Symbol("claude-bridge:rotationState");
141
+
142
+ /** Hard cap on account-rotation attempts per request (CHANGELOG 3.0.0). */
143
+ const MAX_ROTATION_ATTEMPTS = 16;
144
+
145
+ interface RotationRequestState {
146
+ excludedProfileIds: Set<string>;
147
+ attempts: number;
148
+ /** Model id already announced via toast for this request, so up to
149
+ * MAX_ROTATION_ATTEMPTS don't repeat an identical switch notice. */
150
+ announcedModelId?: string;
151
+ }
152
+
153
+ type BridgeStreamOptions = SimpleStreamOptions & {
154
+ [ROTATION_STATE_KEY]?: RotationRequestState;
155
+ };
96
156
 
97
157
  // MODELS is buildModels(getModels("anthropic")) — projection kept in models.js.
98
158
  const MODELS = buildModels(getModels("anthropic"));
99
159
 
100
- let extraUsageHelperInFlight: Promise<string> | null = null;
101
-
102
- function emitRateLimitEvent(payload: Record<string, unknown>): void {
103
- try {
104
- extensionApi?.events?.emit?.(RATE_LIMIT_AUTO_RESUME_EVENT, payload);
105
- } catch {
106
- // Cross-extension broker is best-effort only.
107
- }
108
- }
109
-
110
- function extraUsageAllowed(config: Config): boolean {
111
- return config.provider?.allowExtraUsage === true;
112
- }
113
160
 
114
- function sdkTextFromMessage(message: SDKMessage): string | undefined {
115
- if (message.type === "result") return (message as any).result;
116
- if (message.type === "assistant") {
117
- const content = (message as any).message?.content;
118
- if (!Array.isArray(content)) return undefined;
119
- return content
120
- .map((block) => block?.type === "text" && typeof block.text === "string" ? block.text : "")
121
- .filter(Boolean)
122
- .join("\n");
123
- }
124
- return undefined;
125
- }
126
-
127
- async function runExtraUsageHelper(cwd: string, config = loadConfig(cwd)): Promise<string> {
128
- const providerSettings = config.provider ?? {};
129
- const claudeExecutable = resolveClaudeExecutable(providerSettings.pathToClaudeCodeExecutable);
130
- if (claudeExecutable) preflightClaudeExecutable(claudeExecutable, cwd);
131
-
132
- const helperQuery = query({
133
- prompt: "/extra-usage",
134
- options: {
135
- cwd,
136
- env: { ...process.env, ENABLE_CLAUDEAI_MCP_SERVERS: "0", DISABLE_AUTO_COMPACT: "1" },
137
- maxTurns: 1,
138
- ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
139
- spawnClaudeCodeProcess: spawnClaudeCodeWithDiagnostics,
140
- ...makeCliDebugOptions("extra-usage"),
141
- },
142
- });
143
- const outputs: string[] = [];
144
- try {
145
- for await (const message of helperQuery) {
146
- const text = sdkTextFromMessage(message)?.trim();
147
- if (text && outputs[outputs.length - 1] !== text) outputs.push(text);
148
- }
149
- } finally {
150
- helperQuery.close();
151
- }
152
- return outputs.join("\n").trim() || "Claude Code /extra-usage completed.";
153
- }
154
-
155
- function launchExtraUsageHelperIfAllowed(cwd: string, config: Config, reason: string): boolean {
156
- if (!extraUsageAllowed(config)) return false;
157
- if (extraUsageHelperInFlight) return true;
158
- extraUsageHelperInFlight = runExtraUsageHelper(cwd, config)
159
- .then((message) => {
160
- piUI?.notify(`Claude extra usage helper: ${message}`, "info");
161
- return message;
162
- })
163
- .catch((error) => {
164
- const message = error instanceof Error ? error.message : String(error);
165
- piUI?.notify(`Claude extra usage helper failed after ${reason}: ${message}`, "error");
166
- throw error;
167
- })
168
- .finally(() => { extraUsageHelperInFlight = null; });
169
- void extraUsageHelperInFlight.catch(() => {});
170
- return true;
171
- }
172
161
 
173
162
  // Pi doesn't pass tool results directly — it appends them to the context and calls
174
163
  // the provider again. Thin wrapper over extract-tool-results.js that adds per-turn
@@ -183,49 +172,99 @@ function extractAllToolResults(context: Context): McpResult[] {
183
172
  return results;
184
173
  }
185
174
 
186
- /** Extract the last user message from context as a prompt string. Returns null if last message is not a user message. */
175
+ /** Combine one or more consecutive user messages into a single SDK prompt.
176
+ *
177
+ * Representation divergence, accepted on purpose: this MERGES N pi user
178
+ * messages into ONE Claude user record ("\n\n"-joined), while a REBUILD
179
+ * (convertPiMessages in convert.ts) imports the same pi history as N separate
180
+ * user records. Streaming N SDKUserMessages instead would collapse N pi turns
181
+ * into one Pi reply with double-counted usage, so the join stays. The merged
182
+ * form is only ever a query's live prompt — it is never re-imported, so the
183
+ * two representations never meet in one session file. */
187
184
  function extractUserPrompt(messages: Context["messages"]): string | null {
188
- const last = messages[messages.length - 1];
189
- if (!last || last.role !== "user") return null;
190
- if (typeof last.content === "string") return last.content;
191
- return messageContentToText(last.content) || "";
185
+ if (messages.length === 0 || messages.some((message) => message.role !== "user")) return null;
186
+ return messages.map((message) =>
187
+ typeof message.content === "string" ? message.content : messageContentToText(message.content) || "",
188
+ ).join("\n\n");
192
189
  }
193
190
 
194
- /** Extract the last user message as ContentBlockParam[] (preserving images).
195
- * Returns null if no images — caller should fall back to string prompt. */
191
+ /** Combine consecutive user messages as ContentBlockParam[] while preserving images.
192
+ * Returns null if no images — caller should fall back to the string prompt.
193
+ * Same N-into-1 merge as extractUserPrompt (see its comment for why). */
196
194
  function extractUserPromptBlocks(messages: Context["messages"]): ContentBlockParam[] | null {
197
- const last = messages[messages.length - 1];
198
- if (!last || last.role !== "user") return null;
199
- if (typeof last.content === "string") {
200
- debug(`extractUserPromptBlocks: content is string (length=${last.content.length})`);
201
- return null;
202
- }
203
- if (!Array.isArray(last.content)) {
204
- debug(`extractUserPromptBlocks: content is ${typeof last.content}`);
205
- return null;
206
- }
207
- debug(`extractUserPromptBlocks: ${last.content.length} blocks, types=${last.content.map((b: any) => b.type).join(",")}`);
195
+ if (messages.length === 0 || messages.some((message) => message.role !== "user")) return null;
196
+
208
197
  let hasImage = false;
209
198
  const blocks: ContentBlockParam[] = [];
210
- for (const block of last.content) {
211
- if (block.type === "text" && block.text) {
212
- blocks.push({ type: "text", text: block.text });
213
- } else if (block.type === "image") {
214
- debug(`image block: mimeType=${(block as any).mimeType}, data length=${((block as any).data ?? "").length}, keys=${Object.keys(block).join(",")}`);
215
- if (!(block as any).data || !(block as any).mimeType) {
216
- debug(`image block missing data or mimeType, skipping`);
217
- continue;
199
+ for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
200
+ const content = messages[messageIndex].content;
201
+ if (messageIndex > 0) blocks.push({ type: "text", text: "\n\n" });
202
+ if (typeof content === "string") {
203
+ if (content) blocks.push({ type: "text", text: content });
204
+ continue;
205
+ }
206
+ if (!Array.isArray(content)) {
207
+ debug(`extractUserPromptBlocks: content is ${typeof content}`);
208
+ continue;
209
+ }
210
+ debug(`extractUserPromptBlocks: ${content.length} blocks, types=${content.map((b: any) => b.type).join(",")}`);
211
+ for (const block of content) {
212
+ if (block.type === "text" && block.text) {
213
+ blocks.push({ type: "text", text: block.text });
214
+ } else if (block.type === "image") {
215
+ debug(`image block: mimeType=${(block as any).mimeType}, data length=${((block as any).data ?? "").length}, keys=${Object.keys(block).join(",")}`);
216
+ if (!(block as any).data || !(block as any).mimeType) {
217
+ debug(`image block missing data or mimeType, skipping`);
218
+ continue;
219
+ }
220
+ hasImage = true;
221
+ blocks.push({
222
+ type: "image",
223
+ source: { type: "base64", media_type: block.mimeType as Base64ImageSource["media_type"], data: block.data },
224
+ });
218
225
  }
219
- hasImage = true;
220
- blocks.push({
221
- type: "image",
222
- source: { type: "base64", media_type: block.mimeType as Base64ImageSource["media_type"], data: block.data },
223
- });
224
226
  }
225
227
  }
226
228
  return hasImage ? blocks : null;
227
229
  }
228
230
 
231
+ export interface DeferredUserReplayPlan {
232
+ // Index where the trailing consecutive user run begins (=== messages.length
233
+ // when the context doesn't end in a user message; never below the caller's
234
+ // capturedThrough bound).
235
+ runStart: number;
236
+ userMessageCount: number;
237
+ // All trailing user messages combined into one replay prompt, or null when
238
+ // there is nothing usable to replay (no trailing users, or all-empty text
239
+ // with no image blocks).
240
+ prompt: string | null;
241
+ // Present when the run carries image blocks — the replay must send these
242
+ // (via wrapPromptStream) or the images are silently lost (vstack#993).
243
+ blocks: ContentBlockParam[] | null;
244
+ }
245
+
246
+ /** Plan replay of user messages pi injected mid-query (steer drain, followUp).
247
+ * Captures the ENTIRE trailing consecutive user run, not just the last
248
+ * message — dropping the earlier ones was silent input loss (vstack#967) —
249
+ * but never walks below `capturedThrough`, the position an earlier callback
250
+ * of the SAME query already captured (or deliberately held at, for an
251
+ * all-empty run). Without that lower bound a second mid-query steer re-planned
252
+ * the whole run from scratch and the first steer was queued — and delivered to
253
+ * Claude — twice (vstack#1009). */
254
+ export function planDeferredUserReplay(messages: Context["messages"], capturedThrough = 0): DeferredUserReplayPlan {
255
+ let runStart = messages.length;
256
+ while (runStart > capturedThrough && messages[runStart - 1]?.role === "user") runStart--;
257
+ const trailingUsers = messages.slice(runStart);
258
+ const prompt = trailingUsers.length > 0 ? extractUserPrompt(trailingUsers) : null;
259
+ const blocks = trailingUsers.length > 0 ? extractUserPromptBlocks(trailingUsers) : null;
260
+ return {
261
+ runStart,
262
+ userMessageCount: trailingUsers.length,
263
+ prompt: prompt?.trim() ? prompt : null,
264
+ blocks,
265
+ };
266
+ }
267
+
229
268
  async function* wrapPromptStream(blocks: ContentBlockParam[]): AsyncIterable<SDKUserMessage> {
230
269
  yield {
231
270
  type: "user",
@@ -243,7 +282,7 @@ async function* wrapPromptStream(blocks: ContentBlockParam[]): AsyncIterable<SDK
243
282
  // them without activating the extension. `ctx()`, `pushContext()`, `popContext()`
244
283
  // are imported at the top of this file.
245
284
 
246
- function resolveMcpTools(context: Context, excludeToolName?: string): {
285
+ export function resolveMcpTools(context: Context, excludeToolName?: string): {
247
286
  mcpTools: Tool[];
248
287
  customToolNameToSdk: Map<string, string>;
249
288
  customToolNameToPi: Map<string, string>;
@@ -256,10 +295,29 @@ function resolveMcpTools(context: Context, excludeToolName?: string): {
256
295
 
257
296
  for (const tool of context.tools) {
258
297
  if (tool.name === excludeToolName) continue;
298
+ // Never re-offer a tool the child owns natively. The claude.ai connector
299
+ // namespace belongs to the child's own MCP servers, so a Pi tool sitting
300
+ // on it would be advertised a SECOND time under our prefix — two names
301
+ // for one capability, and the model picking the wrong one gets a real
302
+ // `Tool ... not found` from the dispatcher (memsira#320). It would also
303
+ // be uncallable in any case: a `tool_use` under that namespace is treated
304
+ // as child-executed and never handed to Pi (isChildExecutedTool), so
305
+ // filtering here is what makes the two halves agree end to end.
306
+ if (isChildExecutedTool(tool.name)) {
307
+ debug(`resolveMcpTools: not re-offering child-native tool ${tool.name}`);
308
+ continue;
309
+ }
259
310
  const sdkName = `${MCP_TOOL_PREFIX}${tool.name}`;
260
311
  mcpTools.push(tool);
312
+ // Case-insensitive aliases mean two tools differing only by case would
313
+ // silently overwrite each other's mapping — surface it if it ever happens.
314
+ const lowerName = tool.name.toLowerCase();
315
+ const collision = customToolNameToSdk.get(lowerName);
316
+ if (collision !== undefined && collision !== sdkName) {
317
+ debug(`WARNING: resolveMcpTools lowercase alias collision: ${tool.name} overwrites mapping previously held by ${collision}`);
318
+ }
261
319
  customToolNameToSdk.set(tool.name, sdkName);
262
- customToolNameToSdk.set(tool.name.toLowerCase(), sdkName);
320
+ customToolNameToSdk.set(lowerName, sdkName);
263
321
  customToolNameToPi.set(sdkName, tool.name);
264
322
  customToolNameToPi.set(sdkName.toLowerCase(), tool.name);
265
323
  }
@@ -267,54 +325,12 @@ function resolveMcpTools(context: Context, excludeToolName?: string): {
267
325
  return { mcpTools, customToolNameToSdk, customToolNameToPi };
268
326
  }
269
327
 
270
- /** Finalizes the current pi turn when the SDK invokes an MCP tool handler
271
- * before emitting `message_stop` or the completed assistant message.
272
- *
273
- * Observed with Claude Code under pi 0.80's steer draining (tool result and
274
- * drained steer arrive in one provider call): the NEXT tool turn's tool_use
275
- * streams in, the SDK invokes the MCP handler and neither terminal event
276
- * ever arrives. The invocation itself proves the assistant turn is committed,
277
- * so end the pi stream here exactly like the `message_stop` path; otherwise
278
- * the handler blocks on a result pi will never deliver (deadlock). No-op when
279
- * the turn already ended (stream null) or the tool call isn't part of the
280
- * currently streamed turn. */
281
- function finalizeToolUseTurnFromMcpInvocation(
282
- queryCtx: QueryContext,
283
- toolCallId: string,
284
- toolName: string,
285
- mappedArgs: Record<string, unknown>,
286
- ): void {
287
- if (!queryCtx.currentPiStream || !queryCtx.turnOutput) return;
288
- let idx = queryCtx.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === toolCallId);
289
- if (idx >= 0) {
290
- const block = queryCtx.turnBlocks[idx] as any;
291
- if ("partialJson" in block) {
292
- // Stream ended before content_block_stop — settle the args from the
293
- // partial JSON the same way content_block_stop would have.
294
- block.arguments = mapToolArgs(block.name, parsePartialJson(block.partialJson, block.arguments));
295
- queryCtx.updateToolCallArgs(block.id, block.arguments);
296
- delete block.partialJson;
297
- delete block.index;
298
- queryCtx.currentPiStream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: queryCtx.turnOutput });
299
- }
300
- } else {
301
- // The invocation can arrive before the tool_use is streamed at all
302
- // (observed after a tool-result+steer provider call reset the turn):
303
- // synthesize the toolCall from the claim — the MCP call carries the
304
- // authoritative id, name, and arguments.
305
- queryCtx.turnBlocks.push({ type: "toolCall", id: toolCallId, name: toolName, arguments: mappedArgs });
306
- idx = queryCtx.turnBlocks.length - 1;
307
- const block = queryCtx.turnBlocks[idx] as any;
308
- queryCtx.currentPiStream.push({ type: "toolcall_start", contentIndex: idx, partial: queryCtx.turnOutput });
309
- queryCtx.currentPiStream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: queryCtx.turnOutput });
310
- }
311
- queryCtx.turnSawToolCall = true;
312
- queryCtx.turnOutput.stopReason = "toolUse";
313
- debug(`mcp handler: finalizing tool_use turn from MCP invocation [${toolCallId}] (${toolName}) — SDK invoked the tool before message_stop/assistant message`);
314
- queryCtx.currentPiStream.push({ type: "done", reason: "toolUse", message: queryCtx.turnOutput });
315
- queryCtx.currentPiStream.end();
316
- queryCtx.currentPiStream = null;
317
- }
328
+ // finalizeToolUseTurnFromMcpInvocation moved to assistant-stream.ts: it is now
329
+ // the grace-timer ACTION armed by scheduleToolUseTurnEnd rather than an
330
+ // immediate end. The CLI invokes MCP handlers before message_delta arrives on
331
+ // every tool-use turn, and message_delta is what carries the real output-token
332
+ // count ending the pi stream at handler invocation is what froze pi's
333
+ // per-turn output figures at the message_start placeholders (1–7 tokens).
318
334
 
319
335
  // Creates an MCP server that bridges pi tools to the SDK. Each tool handler
320
336
  // blocks on a Promise until pi delivers the tool result via streamSimple.
@@ -341,23 +357,47 @@ function buildMcpServers(tools: Tool[], queryCtx: QueryContext): Record<string,
341
357
  turnToolCallIds: queryCtx.turnToolCallIds,
342
358
  turnToolCalls: safeToolCallSummary(queryCtx.turnToolCalls),
343
359
  });
360
+ appendIntegrityEntry("tool_handler_unmatched", {
361
+ toolName: tool.name,
362
+ argKeys: argKeys(mappedArgs),
363
+ available: claim.available,
364
+ turnToolCallIds: queryCtx.turnToolCallIds,
365
+ });
344
366
  return { content: [{ type: "text", text: `Claude bridge internal error: no matching tool_call id for ${tool.name}` }], isError: true } satisfies McpResult;
345
367
  }
346
- if (claim.match !== "tool-args" || claim.ambiguous) {
368
+ if (claim.argsMismatch) {
369
+ // Claimed anyway (sole same-name candidate) — record the divergence so
370
+ // a schema/validator drift stays visible without stranding the call.
371
+ debug(`mcp handler: ${tool.name} [${toolCallId}] claimed sole same-name call despite args mismatch`);
372
+ diagDump("tool_claim_args_mismatch", {
373
+ toolName: tool.name,
374
+ toolCallId,
375
+ handlerArgKeys: argKeys(mappedArgs),
376
+ recordedArgKeys: argKeys(queryCtx.turnToolCalls.find((call) => call.id === toolCallId)?.arguments),
377
+ });
378
+ } else if (claim.match !== "tool-args" || claim.ambiguous) {
347
379
  debug(`mcp handler: ${tool.name} [${toolCallId}] claimed by ${claim.match}${claim.ambiguous ? " (ambiguous)" : ""}`);
348
380
  }
349
- if (toolCallId && queryCtx.pendingResults.has(toolCallId)) {
350
- const result = queryCtx.pendingResults.get(toolCallId)!;
351
- queryCtx.pendingResults.delete(toolCallId);
381
+ const earlyResult = toolCallId ? takeQueuedOrParkedResult(queryCtx, toolCallId) : undefined;
382
+ if (earlyResult !== undefined) {
352
383
  queryCtx.markToolResultResolved(toolCallId);
353
- debug(`mcp handler: ${tool.name} [${toolCallId}] → resolved from queue (${queryCtx.pendingResults.size} remaining)`);
354
- return result;
384
+ debug(`mcp handler: ${tool.name} [${toolCallId}] → resolved from queue/parked (${queryCtx.pendingResults.size} queued, ${queryCtx.reapedResults.size} parked remaining)`);
385
+ return earlyResult;
355
386
  }
356
387
  debug(`mcp handler: ${tool.name} [${toolCallId}] → waiting`);
357
- finalizeToolUseTurnFromMcpInvocation(queryCtx, toolCallId, tool.name, mappedArgs);
388
+ // Don't end the pi turn here — message_delta (real output tokens) and
389
+ // message_stop are normally milliseconds behind this invocation. Arm the
390
+ // grace timer instead; it force-finalizes only if they never arrive.
391
+ scheduleToolUseTurnEnd(
392
+ queryCtx,
393
+ () => finalizeToolUseTurnFromMcpInvocation(queryCtx, toolCallId, tool.name, mappedArgs),
394
+ `mcp-invocation:${tool.name}`,
395
+ );
358
396
  return new Promise<McpResult>((resolve) => {
359
397
  queryCtx.pendingToolCalls.set(toolCallId, {
360
398
  toolName: tool.name,
399
+ args: mappedArgs,
400
+ generation: queryCtx.callbackGeneration,
361
401
  resolve: (result) => {
362
402
  queryCtx.markToolResultResolved(toolCallId);
363
403
  resolve(result);
@@ -370,32 +410,6 @@ function buildMcpServers(tools: Tool[], queryCtx: QueryContext): Record<string,
370
410
  return { [MCP_SERVER_NAME]: server };
371
411
  }
372
412
 
373
- // --- Effort level mapping ---
374
- // Pi reasoning levels → CC SDK effort levels
375
-
376
- const REASONING_TO_EFFORT: Record<string, EffortLevel> = {
377
- minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "max", max: "max",
378
- };
379
-
380
- function normalizeEffortOverrideModelKey(value: string): string {
381
- const key = value.trim().toLowerCase();
382
- return key.startsWith(`${PROVIDER_ID}/`) ? key.slice(PROVIDER_ID.length + 1) : key;
383
- }
384
-
385
- export function resolveConfiguredEffort(
386
- modelId: string,
387
- reasoningEffort: EffortLevel | undefined,
388
- providerConfig?: Config["provider"],
389
- ): EffortLevel | undefined {
390
- const target = normalizeEffortOverrideModelKey(modelId);
391
- for (const [key, rawEffort] of Object.entries(providerConfig?.modelEffortOverrides ?? {})) {
392
- const normalizedKey = normalizeEffortOverrideModelKey(key);
393
- if (normalizedKey !== "*" && normalizedKey !== target) continue;
394
- const effort = normalizeEffortLevel(rawEffort) as EffortLevel | undefined;
395
- if (effort) return effort;
396
- }
397
- return (normalizeEffortLevel(providerConfig?.forceEffort) as EffortLevel | undefined) ?? reasoningEffort;
398
- }
399
413
 
400
414
  // --- Provider: streaming function ---
401
415
  //
@@ -412,113 +426,6 @@ export function resolveConfiguredEffort(
412
426
  // currentPiStream, so any leftover messages hit the `!ctx().currentPiStream` guard
413
427
  // in consumeQuery and are skipped before resetTurnState runs.
414
428
 
415
- /** Background consumer: iterates the SDK generator, pushing events to currentPiStream.
416
- * Runs until the query ends. Per turn, the SDK yields stream_events (deltas), then
417
- * an assistant message (completed blocks). On tool_use, the stream is ended by
418
- * whichever path handles it first (processStreamEvent or processAssistantMessage),
419
- * and the MCP handler blocks the generator until pi delivers the tool result. */
420
- async function consumeQuery(
421
- sdkQuery: ReturnType<typeof query>,
422
- customToolNameToPi: Map<string, string>,
423
- model: Model<any>,
424
- cwd: string,
425
- bridgeConfig: Config,
426
- wasAborted: () => boolean,
427
- ): Promise<{ capturedSessionId?: string }> {
428
- let capturedSessionId: string | undefined;
429
-
430
- for await (const message of sdkQuery) {
431
- if (wasAborted()) break;
432
- const queryCtx = ctx();
433
- activeStreamIdleWatchdogs.get(queryCtx)?.noteChunk();
434
- if (!queryCtx.turnOutput) continue;
435
- if (!queryCtx.currentPiStream && !(message.type === "assistant" && queryCtx.turnSawToolCall)) continue;
436
-
437
- switch (message.type) {
438
- case "stream_event":
439
- processStreamEvent(message, customToolNameToPi, model);
440
- break;
441
- case "assistant":
442
- processAssistantMessage(message, model, customToolNameToPi);
443
- break;
444
- case "result":
445
- if (!ctx().turnSawStreamEvent && message.subtype === "success") {
446
- ensureTurnStarted();
447
- const text = message.result || "";
448
- ctx().turnBlocks.push({ type: "text", text });
449
- const idx = ctx().turnBlocks.length - 1;
450
- ctx().currentPiStream?.push({ type: "text_start", contentIndex: idx, partial: ctx().turnOutput });
451
- ctx().currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: text, partial: ctx().turnOutput });
452
- ctx().currentPiStream?.push({ type: "text_end", contentIndex: idx, content: text, partial: ctx().turnOutput });
453
- } else if (message.subtype !== "success" && isExtraUsageRequiredMessage(message)) {
454
- const errorLines = Array.isArray((message as any).errors) ? uniqueNonEmptyLines((message as any).errors) : [];
455
- const errors = errorLines.length > 0 ? errorLines.join("\n") : String(message.subtype ?? "Claude Code rate limit");
456
- const openedExtraUsage = launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "result error");
457
- ctx().handledTerminalError = true;
458
- ctx().turnOutput.stopReason = "error";
459
- ctx().turnOutput.errorMessage = `${errors}${openedExtraUsage ? "\n\nOpened Claude Code /extra-usage helper. Complete billing/admin flow in the browser, then retry the prompt." : "\n\nRun /claude-bridge:extra, or enable Allow extra usage helper in settings."}`;
460
- ctx().currentPiStream?.push({ type: "error", reason: "error", error: ctx().turnOutput });
461
- ctx().currentPiStream?.end();
462
- ctx().currentPiStream = null;
463
- }
464
- break;
465
- case "system":
466
- if ((message as any).subtype === "init" && (message as any).session_id) {
467
- capturedSessionId = (message as any).session_id;
468
- } else if ((message as any).subtype === "model_refusal_fallback") {
469
- const originalModel = (message as any).original_model;
470
- const fallbackModel = (message as any).fallback_model;
471
- updateTurnOutputModel(fallbackModel);
472
- debug("consumeQuery: model_refusal_fallback", JSON.stringify({ originalModel, fallbackModel }));
473
- // Notify only for reroutes we configured, so an unexpected pairing from
474
- // Claude Code is still logged above but not announced as one of ours.
475
- if (typeof fallbackModel === "string" && typeof originalModel === "string" && fallbackModelForPrimaryModel(originalModel) === fallbackModel) {
476
- safeNotify(
477
- `Claude bridge switched ${modelDisplayName(originalModel)} to ${modelDisplayName(fallbackModel)} after Claude Code safety fallback.`,
478
- "info",
479
- );
480
- }
481
- }
482
- break;
483
- case "user":
484
- break; // SDK echo of user prompt — not needed
485
- case "rate_limit_event": {
486
- const info = (message as any).rate_limit_info;
487
- debug("consumeQuery: rate_limit_event", JSON.stringify(info).slice(0, 300));
488
- if (info?.status === "rejected") {
489
- const resetsAt = formatResetTimestamp(info.resetsAt);
490
- const resetAtMs = typeof info.resetsAt === "string" ? Date.parse(info.resetsAt) : undefined;
491
- const reason = `${info.rateLimitType ?? "unknown"} rate limit`;
492
- const launchedExtraUsage = isExtraUsageRequiredMessage(info) && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, reason);
493
- emitRateLimitEvent({
494
- model: model.id,
495
- provider: PROVIDER_ID,
496
- rateLimitType: info.rateLimitType,
497
- reason,
498
- resetAt: info.resetsAt,
499
- ...(Number.isFinite(resetAtMs) ? { resetAtMs } : {}),
500
- source: "claude-bridge",
501
- status: "rejected",
502
- });
503
- piUI?.notify(`${RATE_LIMIT_TOKEN} Claude ${reason} hit — resets ${resetsAt}${launchedExtraUsage ? "; opened /extra-usage helper" : ""}`, "warning");
504
- } else if (info?.status === "allowed_warning") {
505
- const warning = formatAllowedRateLimitWarning(info);
506
- if (warning) piUI?.notify(warning, "warning");
507
- else debug("consumeQuery: suppressed low/ambiguous allowed_warning rate_limit_event", JSON.stringify(info).slice(0, 300));
508
- }
509
- break;
510
- }
511
- default:
512
- debug("consumeQuery: unhandled SDK message type", message.type);
513
- break;
514
- }
515
- }
516
-
517
- // DEBUG: trace when consumeQuery exits
518
- debug(`consumeQuery: for-await loop exited, wasAborted=${wasAborted()}, capturedSessionId=${capturedSessionId?.slice(0, 8) ?? "none"}`);
519
-
520
- return { capturedSessionId };
521
- }
522
429
 
523
430
  // Claim the primary-instance token for this module instance if unclaimed, and
524
431
  // report whether this instance is the primary. First-loaded instance wins,
@@ -532,12 +439,15 @@ function claimPrimaryInstance(): boolean {
532
439
 
533
440
  // Release both process-global tokens this instance owns. Called on
534
441
  // session_shutdown (incl. /reload) so the freshly loaded instance starts clean.
535
- // NOTE: this does NOT unregister the provider — the ModelRegistry's
536
- // registeredProviders is a process-lifetime Map that survives module reload, so
537
- // retraction on logout is handled by applyProviderRegistration's defensive
538
- // unregister on the next load/session_start, not here.
442
+ // NOTE: this does NOT unregister the provider — pi's provider registry is
443
+ // process-lifetime state that survives module reload; the next loaded instance
444
+ // simply upserts its own provider object over ours (registerNativeProvider is
445
+ // replace-by-id), and logout-hiding is the provider's own auth check.
539
446
  function releaseProviderTokens(event: string): void {
540
447
  const g = globalThis as Record<symbol, any>;
448
+ if (g[CLAUDE_BRIDGE_ACCOUNT_HOST_SYMBOL] === BRIDGE_ACCOUNT_HOST) {
449
+ g[CLAUDE_BRIDGE_ACCOUNT_HOST_SYMBOL] = undefined;
450
+ }
541
451
  if (g[ACTIVE_STREAM_SIMPLE_KEY] === streamClaudeAgentSdk) {
542
452
  debug(`${event}: clearing ACTIVE_STREAM_SIMPLE_KEY`);
543
453
  g[ACTIVE_STREAM_SIMPLE_KEY] = undefined;
@@ -548,68 +458,99 @@ function releaseProviderTokens(event: string): void {
548
458
  }
549
459
  }
550
460
 
551
- // Conditional (un)registration driven by real credential presence + instance
552
- // primacy. Run at extension load, on every session_start, and at pre-spawn
553
- // (fail-fast) so a `claude login` / logout is reflected without a /reload.
461
+ // Native (pi >=0.81) provider registration. Run at extension load, on every
462
+ // session_start, and at pre-spawn.
554
463
  //
555
- // decideRegistration encodes the pure state machine; this wrapper performs the
556
- // matching token mutations so tokens and registration never diverge:
557
- // - register: claim the stream guard, THEN registerProvider. If register
558
- // throws/queue-fails, release the stream guard (but keep primacy) so a
559
- // later re-check can retry cleanly (self-healing); errors are swallowed so
560
- // a session_start handler can't crash the dispatch.
561
- // - unregister: pi.unregisterProvider (idempotent), THEN release the stream
562
- // guard if we own it. Defensive even when we never registered — this is the
563
- // only retraction path for a stale registration surviving /reload. At LOAD
564
- // the SDK's unregister only filters the pending-registration queue and can't
565
- // mutate the persistent registry (loader.js), so the effective retraction
566
- // lands on the post-load session_start re-check; the load-time call is a
567
- // harmless idempotent no-op that also cancels any same-pass queued register.
568
- // Non-primary instances (subagents) always decide noop and touch nothing.
464
+ // 2.x registers UNCONDITIONALLY (once primary): credential-driven availability
465
+ // is the provider's own auth.check/resolve reporting configured-ness, so pi
466
+ // hides/shows claude-bridge models itself the 1.x register/unregister state
467
+ // machine (decideRegistration) is gone. What each trigger does now:
468
+ // - load: build + register the provider (queued by the loader until bindCore).
469
+ // - session_start: re-upsert the SAME provider object. registerNativeProvider
470
+ // is upsert-by-id and kicks pi's model-snapshot/availability refresh, so a
471
+ // `claude login`/logout since the last session boundary is reflected
472
+ // deterministically the same guarantee the 1.x re-check gave without
473
+ // depending on pi's own refresh cadence.
474
+ // - pre-spawn: same re-upsert, from the fail-fast path, so a mid-session
475
+ // logout also flips availability at first use.
476
+ // Non-primary instances (subagents) never touch registration: pi's native
477
+ // registry REPLACES by id, so an unguarded subagent re-register would swap in
478
+ // its own streamSimple — the exact split-brain the tokens exist to prevent.
479
+ // On a pre-0.81 host the extension declines loudly (once) instead of
480
+ // registering wrongly through the legacy overload.
481
+ let nativeProviderInstance: unknown;
482
+ let notifiedNativeUnsupported = false;
483
+
569
484
  function applyProviderRegistration(trigger: string): void {
570
485
  const pi = extensionApi;
571
486
  if (!pi) { debug(`${trigger}: applyProviderRegistration skipped — no extensionApi`); return; }
572
487
  const g = globalThis as Record<symbol, any>;
573
488
  const isPrimary = claimPrimaryInstance();
574
- const credentialed = hasClaudeCredentials();
575
- const registered = g[ACTIVE_STREAM_SIMPLE_KEY] === streamClaudeAgentSdk;
576
- const decision = decideRegistration({ credentialed, isPrimary, registered });
577
- debug(`${trigger}: registration decision=${decision} credentialed=${credentialed} isPrimary=${isPrimary} registered=${registered} (module=${moduleInstanceId})`);
578
- if (decision === "register") {
579
- // Claim ordering: stream guard BEFORE registerProvider so a concurrent
580
- // subagent can never observe a registered provider without an owner.
581
- g[ACTIVE_STREAM_SIMPLE_KEY] = streamClaudeAgentSdk;
582
- try {
583
- pi.registerProvider(PROVIDER_ID, {
584
- baseUrl: "claude-bridge",
585
- apiKey: "not-used",
586
- api: "claude-bridge",
587
- models: MODELS,
588
- // Cast: pi-ai AssistantMessageEventStream diamond dep between pi-coding-agent and pi-agent-core
589
- streamSimple: streamClaudeAgentSdk as any,
590
- });
591
- } catch (err) {
592
- // Self-heal: release ONLY the stream guard we just claimed so a later
593
- // re-check (primary + credentialed + not-registered → register) retries.
594
- // Keep PRIMARY_INSTANCE_KEY: releasing it would reopen the subagent
595
- // ownership-steal window, and retry does not need it released.
596
- if (g[ACTIVE_STREAM_SIMPLE_KEY] === streamClaudeAgentSdk) g[ACTIVE_STREAM_SIMPLE_KEY] = undefined;
597
- debug(`${trigger}: registerProvider threw; released stream guard for retry (kept primary):`, err);
598
- }
599
- } else if (decision === "unregister") {
600
- try {
601
- pi.unregisterProvider(PROVIDER_ID);
602
- } catch (err) {
603
- debug(`${trigger}: unregisterProvider threw (ignored):`, err);
489
+ if (!isPrimary) {
490
+ debug(`${trigger}: registration noop non-primary instance (module=${moduleInstanceId})`);
491
+ return;
492
+ }
493
+ if (!supportsNativeProvider(_piAi)) {
494
+ debug(`${trigger}: host pi-ai lacks createProvider; refusing to register (module=${moduleInstanceId})`);
495
+ if (!notifiedNativeUnsupported) {
496
+ notifiedNativeUnsupported = true;
497
+ safeNotify(NATIVE_PROVIDER_UNSUPPORTED_MESSAGE, "error");
604
498
  }
499
+ return;
500
+ }
501
+ const credentialed = hasClaudeCredentials() || Boolean(resolveClaudeAccountRouter());
502
+ debug(`${trigger}: native registration upsert, credentialed=${credentialed} (module=${moduleInstanceId})`);
503
+ // Start the connector inventory now, not on the first turn: the query path
504
+ // can only read a synchronous snapshot, so priming here is what gets the
505
+ // declarations in place before turn 1 (vstack#832). Fire and forget —
506
+ // registration must not wait on the network. Primes the DEFAULT credential
507
+ // scope only; managed profiles are primed per request in their own scope.
508
+ if (hasClaudeCredentials() && connectorsEnabledFor(loadConfig(process.cwd()))) primeConnectorServers();
509
+ // Claim ordering: stream guard BEFORE registerProvider so a concurrent
510
+ // subagent can never observe a registered provider without an owner.
511
+ g[ACTIVE_STREAM_SIMPLE_KEY] = streamClaudeAgentSdk;
512
+ try {
513
+ nativeProviderInstance ??= buildNativeProvider(
514
+ _piAi,
515
+ MODELS,
516
+ streamClaudeAgentSdk as (...args: unknown[]) => unknown,
517
+ process.env,
518
+ // Availability includes a companion account pool: the router owns
519
+ // credentials the direct existence probes cannot see.
520
+ () => hasClaudeCredentials() || Boolean(resolveClaudeAccountRouter()),
521
+ );
522
+ (pi.registerProvider as (provider: unknown) => void)(nativeProviderInstance);
523
+ } catch (err) {
524
+ // Self-heal: release ONLY the stream guard we just claimed so a later
525
+ // re-check retries cleanly. Keep PRIMARY_INSTANCE_KEY: releasing it would
526
+ // reopen the subagent ownership-steal window.
605
527
  if (g[ACTIVE_STREAM_SIMPLE_KEY] === streamClaudeAgentSdk) g[ACTIVE_STREAM_SIMPLE_KEY] = undefined;
528
+ debug(`${trigger}: registerProvider threw; released stream guard for retry (kept primary):`, err);
606
529
  }
607
530
  }
608
531
 
609
532
  /** Provider entry point. Pi calls this for each new prompt and each tool result.
610
- * Two cases: tool result delivery (active query) or fresh query. */
611
- function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
533
+ * Two cases: tool result delivery (active query) or fresh query. Exported for
534
+ * the rotation-stream unit tests, which drive it with a fake SDK factory. */
535
+ export function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
536
+ return runInRequestLane(options?.sessionId, () => streamClaudeAgentSdkInLane(model, context, options));
537
+ }
538
+
539
+ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
612
540
  const stream = newAssistantMessageEventStream();
541
+ // The lane this request runs in, for callbacks that fire OUTSIDE it: an
542
+ // AbortSignal listener runs in the aborter's async context, not ours.
543
+ const laneId = currentRequestLaneId();
544
+ // Pi marks its compaction and branch-summary one-shots with cacheRetention
545
+ // "none" and a fresh sessionId per call; no session_shutdown ever prunes
546
+ // those lanes. Map the hint onto lane lifetime: nothing about this request
547
+ // is retained once it settles.
548
+ const ephemeralLane = laneId !== undefined && options?.cacheRetention === "none";
549
+ const releaseEphemeralLane = (): void => {
550
+ if (!ephemeralLane) return;
551
+ deleteSharedSessionLane(laneId);
552
+ deleteQueryLane(laneId);
553
+ };
613
554
 
614
555
  // DEBUG: trace followUp message triggering
615
556
  const lastMsgRole = context.messages[context.messages.length - 1]?.role;
@@ -624,13 +565,20 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
624
565
  const queryCtx = ctx();
625
566
  queryCtx.currentPiStream = stream;
626
567
  queryCtx.resetTurnState(model);
568
+ // A fresh callback separates handlers registered for settled turns from
569
+ // ones racing this callback's own stream — the stranded drain below only
570
+ // ever touches the former.
571
+ queryCtx.callbackGeneration += 1;
627
572
  activeStreamIdleWatchdogs.get(queryCtx)?.refresh();
628
573
  const allResults = extractAllToolResults(context);
629
574
  debug(`provider: tool results, ${allResults.length} results, ${queryCtx.pendingToolCalls.size} waiting handlers, ctx.msgs=${context.messages.length}`);
630
575
  const unmatchedResultIds: string[] = [];
631
576
  for (const result of allResults) {
632
577
  const id = result.toolCallId;
633
- if (id && !queryCtx.hasRecordedToolCall(id)) {
578
+ if (id && !queryCtx.hasRecordedToolCall(id) && !queryCtx.forwardedToolCallIds.has(id)) {
579
+ // A forwarded id is always legitimate even after the per-message
580
+ // records reset — Pi only answers calls it was handed (steer-split
581
+ // results land here after a boundary wiped the turn records).
634
582
  queryCtx.markToolResultUnmatched(id);
635
583
  unmatchedResultIds.push(id);
636
584
  debug(`ERROR: tool result [${id}] has no registered tool_call id; refusing to queue or deliver`);
@@ -649,7 +597,9 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
649
597
  debug(`WARNING: tool result without toolCallId, cannot match`);
650
598
  }
651
599
  if (queryCtx.pendingToolCalls.size > 0 && queryCtx.pendingResults.size > 0) {
652
- debug(`BUG: both maps non-empty! handlers=${queryCtx.pendingToolCalls.size} results=${queryCtx.pendingResults.size}`);
600
+ // Legitimate under staggered SDK invocation (a waiting steer-split
601
+ // handler while a sibling's result queues) — informational only.
602
+ debug(`note: handlers and queued results coexist: handlers=${queryCtx.pendingToolCalls.size} results=${queryCtx.pendingResults.size}`);
653
603
  }
654
604
  }
655
605
  if (unmatchedResultIds.length > 0) {
@@ -657,13 +607,33 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
657
607
  content: [{ type: "text", text: `Claude bridge internal error: ${unmatchedResultIds.length} tool result(s) did not match any registered tool_call id. The turn was stopped to avoid delivering tool output to the wrong call. Unmatched ids: ${unmatchedResultIds.slice(0, 8).join(", ")}${unmatchedResultIds.length > 8 ? ", ..." : ""}` }],
658
608
  isError: true,
659
609
  };
660
- for (const pending of queryCtx.pendingToolCalls.values()) pending.resolve(errorResult);
610
+ for (const [pendingId, pending] of queryCtx.pendingToolCalls) {
611
+ // The model is told these calls were stopped; an unforwarded one must
612
+ // never be dispatched by a later replay behind that message’s back.
613
+ if (!queryCtx.forwardedToolCallIds.has(pendingId)) queryCtx.deadToolCallIds.add(pendingId);
614
+ pending.resolve(errorResult);
615
+ }
661
616
  queryCtx.pendingToolCalls.clear();
662
617
  reportToolResultMismatch(queryCtx, "unmatched tool result", cwd);
663
618
  }
664
619
  if (queryCtx.pendingToolCalls.size > 0) {
665
- debug(`WARNING: ${queryCtx.pendingToolCalls.size} MCP handlers still waiting after delivering ${allResults.length} results`);
666
- piUI?.notify(`Claude bridge: ${queryCtx.pendingToolCalls.size} tool handler(s) still waiting provider may be stuck`, "warning");
620
+ // A waiting handler whose call never reached Pi can never be answered —
621
+ // fail it now with a retryable error instead of letting the SDK await it
622
+ // forever (the 2026-08-17 five-and-a-half-hour deadlock, vstack#1469).
623
+ // Forwarded-but-unanswered handlers stay: steer-split batches legitimately
624
+ // deliver their results in a later callback.
625
+ const stranded = drainStrandedToolCalls(queryCtx);
626
+ if (stranded.length > 0) {
627
+ const names = stranded.map((entry) => entry.toolName).join(", ");
628
+ debug(`provider: failed ${stranded.length} stranded MCP handler(s) never forwarded to Pi: ${names}`);
629
+ diagDump("tool_handlers_stranded", { count: stranded.length, stranded });
630
+ appendIntegrityEntry("tool_handlers_stranded", { count: stranded.length, stranded });
631
+ safeNotify(`Claude bridge: failed ${stranded.length} tool call(s) that never reached Pi before their turn ended (${names}). The model saw a retryable error.`, "warning");
632
+ }
633
+ if (queryCtx.pendingToolCalls.size > 0) {
634
+ debug(`WARNING: ${queryCtx.pendingToolCalls.size} MCP handlers still waiting after delivering ${allResults.length} results`);
635
+ safeNotify(`Claude bridge: ${queryCtx.pendingToolCalls.size} tool handler(s) still waiting — provider may be stuck`, "warning");
636
+ }
667
637
  }
668
638
 
669
639
  // Detect user messages (steer/followUp) that pi injected into context
@@ -674,16 +644,53 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
674
644
  // - A followUp is delivered between tool-result turns.
675
645
  // The bridge can't forward these mid-query (the SDK query is in progress),
676
646
  // so we save them for replay as continuation queries after consumeQuery ends.
647
+ // The cursor may only advance over messages actually captured for replay:
648
+ // claiming Claude owns a user message that was never deferred is permanent
649
+ // silent input loss (vstack#967 — only the LAST of several trailing user
650
+ // messages was captured while the cursor skipped them all).
651
+ let capturedThrough = context.messages.length;
677
652
  if (lastMsgRole === "user") {
678
- const userPrompt = extractUserPrompt(context.messages);
679
- if (userPrompt) {
680
- ctx().deferredUserMessages.push(userPrompt);
681
- debug(`provider: deferred user message for replay after query: ${userPrompt.slice(0, 60)}`);
653
+ // Bound the plan at this query's own captured position (latestCursor
654
+ // Math.max-advances with every callback's capturedThrough below), so a
655
+ // second steer callback only queues messages BEYOND what the first one
656
+ // already owns re-planning the whole trailing run queued the earlier
657
+ // steer twice (vstack#1009). latestCursor, not the shared record's
658
+ // cursor, deliberately: it lives on this QueryContext, so it is correct
659
+ // for reentrant and detached foreign queries too, whose contexts the
660
+ // shared cursor does not index (vstack#1001).
661
+ const replay = planDeferredUserReplay(context.messages, queryCtx.latestCursor);
662
+ // Image-only runs have no usable text but must still replay — capture
663
+ // whenever EITHER form has content (vstack#993).
664
+ if (replay.prompt || replay.blocks) {
665
+ ctx().deferredUserMessages.push({ text: replay.prompt ?? "", blocks: replay.blocks ?? undefined });
666
+ debug(`provider: deferred ${replay.userMessageCount} user message(s) for replay after query${replay.blocks ? ` (${replay.blocks.length} blocks incl. images)` : ""}: ${(replay.prompt ?? "[image-only]").slice(0, 60)}`);
667
+ } else {
668
+ capturedThrough = replay.runStart;
669
+ diagDump("deferred_user_replay_skipped", {
670
+ contextLength: context.messages.length,
671
+ runStart: replay.runStart,
672
+ userMessageCount: replay.userMessageCount,
673
+ messageRoles: context.messages.map((m, i) => `[${i}]${m.role}`).join(" "),
674
+ });
682
675
  }
683
676
  }
684
677
 
685
- if (sharedSession) sharedSession.cursor = context.messages.length;
686
- queryCtx.latestCursor = Math.max(queryCtx.latestCursor, context.messages.length);
678
+ // Cursor may only ADVANCE, and only for a query that holds the record's
679
+ // claim. A reentrant subagent call routed through this instance arrives
680
+ // here with a SHORT foreign context (its own [user…] conversation, not
681
+ // the one the cursor indexes) — writing its length used to shrink the
682
+ // parent cursor and make the next REUSE replay already-owned history.
683
+ // The stackDepth guard covers a pushed subagent context; the detached
684
+ // flag covers a foreign one-shot on the top-level ctx, whose GROWN
685
+ // mid-query context could otherwise out-length the parent's cursor and
686
+ // advance it past history Claude never saw (vstack#1001); Math.max
687
+ // remains the backstop for a legacy-record foreign context the
688
+ // fingerprint guard could not classify.
689
+ const activeSession = getSharedSession();
690
+ if (activeSession && stackDepth() === 0 && !queryCtx.detachedFromSharedSession) {
691
+ setSharedSession({ ...activeSession, cursor: Math.max(activeSession.cursor, capturedThrough) });
692
+ }
693
+ queryCtx.latestCursor = Math.max(queryCtx.latestCursor, capturedThrough);
687
694
  return stream;
688
695
  }
689
696
 
@@ -693,12 +700,17 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
693
700
  const lastMsg = context.messages[context.messages.length - 1];
694
701
  if (lastMsg?.role === "toolResult") {
695
702
  debug(`provider: orphaned tool result after abort, emitting end_turn`);
696
- if (sharedSession) sharedSession.cursor = context.messages.length;
703
+ // The detached flag deliberately survives query end: an orphaned result
704
+ // from a foreign one-shot indexes ITS conversation, and writing that
705
+ // length here would move (even shrink) the parent's cursor (vstack#1001).
706
+ const activeSession = getSharedSession();
707
+ if (activeSession && stackDepth() === 0 && !ctx().detachedFromSharedSession) setSharedSession({ ...activeSession, cursor: context.messages.length });
697
708
  const c = ctx(); // capture current context for the microtask
698
709
  queueMicrotask(() => {
699
710
  c.resetTurnState(model);
700
711
  stream.push({ type: "done", reason: "stop", message: c.turnOutput });
701
712
  stream.end();
713
+ releaseEphemeralLane();
702
714
  });
703
715
  return stream;
704
716
  }
@@ -708,13 +720,13 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
708
720
  // Fail-fast credential re-check (only for a fresh query — NEVER for
709
721
  // tool-result delivery of an in-flight query, handled above, where creds were
710
722
  // valid at start and failing mid-turn would break tool pairing). This bounds
711
- // the retraction-latency window from "next session boundary" to "first use":
712
- // if credentials vanished since the last session_start, (a) trigger the same
713
- // re-evaluation applyProviderRegistration does (primary-only; retracts the
714
- // stale registration), and (b) fail this request with a clear, actionable
723
+ // the logout-visibility window from "next session boundary" to "first use":
724
+ // if neither a direct Claude login nor a companion account pool exists,
725
+ // (a) re-upsert the provider (primary-only) so pi's availability recompute
726
+ // hides the models, and (b) fail this request with a clear, actionable
715
727
  // message instead of letting the SDK spawn die with a generic error. The
716
728
  // check is cheap (existsSync + env reads only, no credential contents).
717
- if (!hasClaudeCredentials()) {
729
+ if (!hasClaudeCredentials() && !resolveClaudeAccountRouter()) {
718
730
  try { applyProviderRegistration("pre-spawn"); } catch { /* best effort */ }
719
731
  const message = "Claude account not connected — connect an account (or run `claude login`) and retry.";
720
732
  debug(`provider: pre-spawn credential check failed; failing fast: ${message}`);
@@ -729,6 +741,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
729
741
  queueMicrotask(() => {
730
742
  stream.push({ type: "error", reason: "error", error: errorOutput });
731
743
  stream.end();
744
+ releaseEphemeralLane();
732
745
  });
733
746
  return stream;
734
747
  }
@@ -743,25 +756,167 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
743
756
  ctx().currentPiStream = stream;
744
757
  ctx().pendingToolCalls.clear();
745
758
  ctx().pendingResults.clear();
759
+ ctx().reapedResults.clear();
760
+ ctx().forwardedToolCallIds.clear();
761
+ ctx().deadToolCallIds.clear();
762
+ ctx().callbackGeneration = 0;
746
763
  ctx().deferredUserMessages = [];
747
764
  ctx().resetTurnState(model);
748
765
  ctx().resetToolTracking();
749
766
  ctx().latestCursor = 0;
767
+ ctx().committedOutput = false;
768
+ // A reentrant query never claims the shared record; a foreign-conversation
769
+ // one-shot joins it below once syncSharedSession has ruled.
770
+ ctx().detachedFromSharedSession = isReentrant;
771
+
772
+ // --- Account routing (optional) ---
773
+ // A companion router selects the subscription profile for this attempt.
774
+ // Rotation state rides the options object so a retry re-entry excludes the
775
+ // profiles that already failed this request.
776
+ const router = resolveClaudeAccountRouter();
777
+ const rotationOptions = options as BridgeStreamOptions | undefined;
778
+ const rotationState: RotationRequestState = rotationOptions?.[ROTATION_STATE_KEY] ?? {
779
+ excludedProfileIds: new Set<string>(),
780
+ attempts: 0,
781
+ };
782
+ let account: ClaudeAccountRoute | undefined;
783
+ if (router) {
784
+ try {
785
+ account = router.acquire({
786
+ modelId: model.id,
787
+ sessionId: options?.sessionId,
788
+ excludedProfileIds: [...rotationState.excludedProfileIds],
789
+ forceRerank: rotationState.attempts > 0,
790
+ reason: rotationState.attempts > 0 ? "automatic-failover" : undefined,
791
+ });
792
+ rotationState.attempts += 1;
793
+ } catch (error) {
794
+ // No profile is available (all cooling down / none configured). Fail
795
+ // the request before spawning anything, carrying the router's reset
796
+ // hint so pi-qol can schedule an auto-resume.
797
+ const message = error instanceof Error ? error.message : String(error);
798
+ const resetAtMs = Number((error as { resetAtMs?: unknown })?.resetAtMs);
799
+ const rateLimitType = (error as { rateLimitType?: unknown })?.rateLimitType;
800
+ if (ctx().turnOutput) {
801
+ ctx().turnOutput.stopReason = "error";
802
+ ctx().turnOutput.errorMessage = message;
803
+ if (Number.isFinite(resetAtMs)) {
804
+ Object.assign(ctx().turnOutput as AssistantMessage & Record<string, unknown>, { resetAtMs, rateLimitType });
805
+ }
806
+ }
807
+ if (Number.isFinite(resetAtMs)) {
808
+ emitRateLimitEvent({
809
+ model: model.id,
810
+ provider: model.provider,
811
+ rateLimitType: rateLimitType ?? "all_accounts",
812
+ reason: message,
813
+ resetAt: new Date(resetAtMs).toISOString(),
814
+ resetAtMs,
815
+ source: "claude-bridge",
816
+ status: "rejected",
817
+ });
818
+ }
819
+ const errorOutput = ctx().turnOutput!;
820
+ if (isReentrant) popContext();
821
+ queueMicrotask(() => {
822
+ stream.push({ type: "error", reason: "error", error: errorOutput });
823
+ stream.end();
824
+ releaseEphemeralLane();
825
+ });
826
+ return stream;
827
+ }
828
+ }
829
+ const queryModel = account?.modelId && account.modelId !== model.id
830
+ ? { ...model, id: account.modelId, name: modelDisplayName(account.modelId) }
831
+ : model;
832
+ if (queryModel.id !== model.id) {
833
+ // Stamp the output model on EVERY attempt (each retry resets turn state),
834
+ // but toast each distinct model at most once per request: with up to 16
835
+ // rotation attempts, every retry re-enters this block and would otherwise
836
+ // repeat an identical switch notice. A DIFFERENT model first selected
837
+ // mid-rotation still announces itself.
838
+ updateTurnOutputModel(queryModel.id);
839
+ if (rotationState.announcedModelId !== queryModel.id) {
840
+ rotationState.announcedModelId = queryModel.id;
841
+ safeNotify(
842
+ account?.fallbackReason === "fable-quota"
843
+ ? `Every ready account rejected Claude Fable; using ${modelDisplayName(queryModel.id)}.`
844
+ : `Pi Claude switched to ${modelDisplayName(queryModel.id)}.`,
845
+ "info",
846
+ );
847
+ }
848
+ }
849
+ // Buffer protocol setup events until the first visible output so a failed
850
+ // pre-output attempt can be retried on another profile without leaking a
851
+ // duplicate `start` frame into Pi. The query context is captured ONCE here:
852
+ // commit can fire while a reentrant subagent context is pushed, and stamping
853
+ // the live ctx() then would mark the WRONG query as committed and leave this
854
+ // one replayable after visible output.
855
+ const attemptCtx = ctx();
856
+ const attemptBuffer = account
857
+ ? new RetryEventBuffer(stream, () => attemptCtx.markOutputCommitted())
858
+ : undefined;
859
+ if (attemptBuffer) attemptCtx.currentPiStream = attemptBuffer as unknown as AssistantMessageEventStream;
750
860
 
751
861
  const { mcpTools, customToolNameToSdk, customToolNameToPi } = resolveMcpTools(context);
752
- const promptBlocks = extractUserPromptBlocks(context.messages);
753
- let promptText = extractUserPrompt(context.messages) ?? "";
754
862
 
755
- // Guard: empty prompt means the last context message isn't a user message.
756
- // This should never happen with the state stack fix — dump diagnostics if it does.
757
- if (!promptText && !promptBlocks) {
863
+ // Config + executable preflight run BEFORE syncSharedSession on purpose: the
864
+ // sync's REBUILD path is destructive (deleteSession + createSession + save),
865
+ // so a misconfigured executable must fail this query while the previous
866
+ // session file is still intact.
867
+ const bridgeConfig = loadConfig(cwd);
868
+ const providerSettings = bridgeConfig.provider ?? {};
869
+ const claudeExecutable = resolveClaudeExecutable(providerSettings.pathToClaudeCodeExecutable);
870
+ const claudeExecutablePreflight = claudeExecutable ? preflightClaudeExecutable(claudeExecutable, cwd) : undefined;
871
+
872
+ const accountScope = accountSessionScope(account);
873
+ const cursorBeforeSync = getSharedSession()?.cursor ?? null;
874
+ // A REENTRANT (subagent) query never touches the module-level shared
875
+ // session: syncSharedSession's REBUILD path is destructive to the PARENT's
876
+ // session file, and any resume id borrowed from the parent would splice the
877
+ // subagent's turn into the parent's conversation of record. It runs as a
878
+ // clean one-shot instead (Case-1 semantics — no resume, prompt is the
879
+ // trailing message; the child session id lives in the QueryContext only).
880
+ const syncResult = isReentrant
881
+ ? { sessionId: null, promptStart: context.messages.length - 1 }
882
+ : syncSharedSession(context.messages, cwd, customToolNameToSdk, queryModel.id, accountScope);
883
+ const { sessionId: resumeSessionId, promptStart } = syncResult;
884
+ // A FOREIGN-conversation query (conversation-fingerprint mismatch against
885
+ // the shared record — a subagent-shaped request arriving while the parent
886
+ // is IDLE, vstack#1001) also runs as a clean one-shot and gets the same
887
+ // hands-off treatment as a reentrant one below: never persist over the
888
+ // module-level record, never mark it for rebuild. The flag also rides the
889
+ // QueryContext so the mismatch/abort/teardown paths that mutate the record
890
+ // OUTSIDE this closure (reportToolResultMismatch, the cursor advances in
891
+ // the delivery paths above) observe the same non-claim.
892
+ const foreignContext = syncResult.foreignContext === true;
893
+ if (foreignContext) ctx().detachedFromSharedSession = true;
894
+ // Identity anchor stamped onto every record this outermost query persists,
895
+ // so the record created by a Case-1 clean start is protected from the very
896
+ // next idle-window foreign query.
897
+ const conversationFp = isReentrant || foreignContext ? undefined : conversationFingerprint(context.messages);
898
+ const promptMessages = context.messages.slice(promptStart);
899
+ const promptBlocks = extractUserPromptBlocks(promptMessages);
900
+ let promptText = extractUserPrompt(promptMessages) ?? "";
901
+
902
+ // Guard: a prompt with no usable content means the last context message
903
+ // isn't a user message (or the batch was all-empty — joined batches turn ""
904
+ // into "\n\n", so test the trimmed text, not truthiness). Should never
905
+ // happen with the state stack fix — dump diagnostics if it does.
906
+ if (!promptText.trim() && !promptBlocks) {
758
907
  diagDump("empty_prompt", {
759
908
  contextLength: context.messages.length,
760
909
  lastMsgRole: lastMsg?.role,
761
910
  isReentrant,
762
911
  stackDepth: stackDepth(),
763
912
  activeQueryExists: ctx().activeQuery !== null,
764
- sharedSession: sharedSession ? { sessionId: sharedSession.sessionId.slice(0, 8), cursor: sharedSession.cursor } : null,
913
+ cursorBeforeSync,
914
+ promptStart,
915
+ promptRoles: promptMessages.map((m) => m.role).join(" "),
916
+ sharedSession: (() => {
917
+ const activeSession = getSharedSession();
918
+ return activeSession ? { sessionId: activeSession.sessionId.slice(0, 8), cursor: activeSession.cursor } : null;
919
+ })(),
765
920
  messageRoles: context.messages.map((m, i) => `[${i}]${m.role}`).join(" "),
766
921
  });
767
922
  // Recover: use a continuation prompt so the SDK doesn't send an empty text block
@@ -772,109 +927,78 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
772
927
  ? wrapPromptStream(promptBlocks)
773
928
  : promptText;
774
929
  const mcpServers = buildMcpServers(mcpTools, ctx());
775
- const bridgeConfig = loadConfig(cwd);
776
- const providerSettings = bridgeConfig.provider ?? {};
777
- // Whether to expose the Claude account's claude.ai cloud MCP connectors
778
- // (Gmail/Calendar/Drive). Enabled via env or config; drives setting-sources,
779
- // tool isolation, and the ENABLE_CLAUDEAI_MCP_SERVERS child-env gate below.
780
- const enableCloudMcp = connectorsEnabledFor(bridgeConfig);
781
- // Connector WRITE control: read-only by default (writes denied); the one-shot
782
- // approved-write executor sets CLAUDE_BRIDGE_CONNECTOR_WRITE=allow / config.
783
- const connectorWriteMode = connectorWriteModeFor(bridgeConfig);
784
- const appendSystemPrompt = providerSettings.appendSystemPrompt !== false;
785
- const agentsAppend = appendSystemPrompt ? extractAgentsAppend() : undefined;
786
- const skillsAppend = appendSystemPrompt ? extractSkillsBlock(context.systemPrompt) : undefined;
787
- const promptContextAppend = buildPromptContextAppend(context.systemPrompt, cwd, bridgeConfig.promptContext ?? {});
788
- const appendParts = [agentsAppend, skillsAppend, promptContextAppend.text].filter((part): part is string => Boolean(part));
789
- const systemPromptAppend = appendParts.length > 0 ? appendParts.join("\n\n") : undefined;
790
-
791
- // MCP auto-loading suppression: with appendSystemPrompt=true (default), the
792
- // SDK uses isolation mode and avoids filesystem settings. If users turn that
793
- // off, load user/project settings but pass --strict-mcp-config so Claude Code
794
- // ignores auto-discovered filesystem MCP servers while Pi owns tool execution.
795
- // claude.ai cloud MCP connectors only load when Claude Code resolves its
796
- // filesystem setting sources. The SDK treats settingSources=undefined as
797
- // isolation (no sources), which drops the connectors even with
798
- // ENABLE_CLAUDEAI_MCP_SERVERS=1. When connectors are enabled we force the CLI
799
- // default source set so Gmail/Calendar/Drive surface.
800
- const settingSources: SettingSource[] | undefined = enableCloudMcp
801
- ? (providerSettings.settingSources ?? ["user", "project", "local"])
802
- : appendSystemPrompt
803
- ? undefined
804
- : providerSettings.settingSources ?? ["user", "project"];
805
- const strictMcpConfigEnabled = !appendSystemPrompt && providerSettings.strictMcpConfig !== false;
806
- const claudeExecutable = resolveClaudeExecutable(providerSettings.pathToClaudeCodeExecutable);
807
- const claudeExecutablePreflight = claudeExecutable ? preflightClaudeExecutable(claudeExecutable, cwd) : undefined;
808
- const { sessionId: resumeSessionId } = syncSharedSession(context.messages, cwd, customToolNameToSdk, model.id);
809
-
810
- // Prefer the model's own thinkingLevelMap when present (pi-ai 0.72+ ships
811
- // per-model overrides — e.g. opus-4-7 wants xhigh→xhigh, not xhigh→max).
812
- // Fall back to our generic table for older pi-ai or unmapped levels.
813
- const requestedEffort = options?.reasoning
814
- ? ((model as any).thinkingLevelMap?.[options.reasoning] as EffortLevel | undefined)
815
- ?? REASONING_TO_EFFORT[options.reasoning]
816
- : undefined;
817
- const effort = resolveConfiguredEffort(model.id, requestedEffort, providerSettings);
818
-
819
- const extraArgs: Record<string, string | null> = {};
820
- if (strictMcpConfigEnabled) extraArgs["strict-mcp-config"] = null;
821
- // Opus 4.7 defaults thinking.display to "omitted" (empty thinking text in stream).
822
- // Force summarized so thinking_delta events arrive. See anthropics/claude-agent-sdk-python#830.
823
- if (effort) extraArgs["thinking-display"] = "summarized";
824
- const fallbackModel = fallbackModelForPrimaryModel(model.id);
825
-
826
- // Suppress claude.ai cloud MCP servers (Figma/Canva/etc. auto-discovered via OAuth
827
- // when the user is logged into Anthropic). These are a separate code path from
828
- // filesystem MCP and are NOT blocked by --strict-mcp-config or settingSources=undefined.
829
- // The native CC binary gates them on env var ENABLE_CLAUDEAI_MCP_SERVERS: setting it
830
- // to "0"/"false"/"no"/"off" makes the loader return early before any cloud fetch.
831
- // DISABLE_AUTO_COMPACT=1: pi owns context-management and propagates its own
832
- // /compact via session_compact (see handler in default export). Letting CC
833
- // also autocompact would double-flush the prompt cache and races pi's
834
- // threshold with CC's, including CC's anti-thrashing guard (issue #8).
835
- // Manual /compact in CC still works (we never invoke it).
836
- // When connectors are enabled, allow claude.ai cloud MCP servers so the
837
- // authenticated account's Gmail/Calendar/Drive tools load. Default stays "0".
838
- const childEnv = { ...process.env, ENABLE_CLAUDEAI_MCP_SERVERS: enableCloudMcp ? "1" : "0", DISABLE_AUTO_COMPACT: "1" };
839
- const queryOptions: NonNullable<Parameters<typeof query>[0]["options"]> = {
930
+ // Pure SDK query-option assembly — see buildClaudeQueryOptions for the
931
+ // connector, prompt-append, setting-source, effort, and env rationale.
932
+ const built = buildClaudeQueryOptions({
840
933
  cwd,
841
- model: model.id,
842
- env: childEnv,
843
- ...connectorQueryOptions(enableCloudMcp, connectorWriteMode),
844
- permissionMode: "bypassPermissions",
845
- includePartialMessages: true,
846
- ...(fallbackModel ? { fallbackModel } : {}),
847
- ...(providerSettings.fastMode ? { settings: { fastMode: true } } : {}),
848
- systemPrompt: {
849
- type: "preset", preset: "claude_code",
850
- append: systemPromptAppend ? systemPromptAppend : undefined,
851
- },
852
- extraArgs,
853
- ...(effort ? { effort } : {}),
854
- ...(settingSources ? { settingSources } : {}),
855
- ...(mcpServers ? { mcpServers } : {}),
856
- ...(resumeSessionId ? { resume: resumeSessionId } : {}),
857
- ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
858
- spawnClaudeCodeProcess: spawnClaudeCodeWithDiagnostics,
859
- ...makeCliDebugOptions("provider"),
860
- };
934
+ requestedModel: model,
935
+ queryModel,
936
+ account,
937
+ bridgeConfig,
938
+ systemPrompt: context.systemPrompt,
939
+ reasoning: options?.reasoning,
940
+ resumeSessionId,
941
+ mcpServers,
942
+ claudeExecutable,
943
+ });
944
+ const { queryOptions } = built;
861
945
 
862
946
  debug("provider: fresh query",
863
- `model=${model.id} msgs=${context.messages.length} tools=${mcpTools.length}`,
864
- `resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
865
- `fallback=${fallbackModel ?? "none"}`,
866
- `appendSys=${appendSystemPrompt} promptCtx=${promptContextAppend.labels.join(",") || "none"} strictMcp=${strictMcpConfigEnabled} fastMode=${providerSettings.fastMode === true} connectors=${enableCloudMcp}`,
947
+ `model=${queryModel.id} requested=${model.id} msgs=${context.messages.length} tools=${mcpTools.length}`,
948
+ `resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${built.effort ?? "default"} account=${account?.label ?? "legacy"}`,
949
+ `fallback=${built.fallbackModel ?? "none"}`,
950
+ `appendSys=${built.appendSystemPrompt} promptCtx=${built.promptContextLabels.join(",") || "none"} strictMcp=${built.strictMcpConfigEnabled} fastMode=${providerSettings.fastMode === true} connectors=${built.enableCloudMcp}`,
867
951
  `claudeExec=${claudeExecutablePreflight ? `${claudeExecutablePreflight.fileType}:${claudeExecutablePreflight.path}` : "sdk-default"}`,
868
952
  `prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`);
869
953
 
870
954
  // 3. Start SDK query and claim it for this context
871
955
  let wasAborted = false;
872
956
  let streamIdleTimedOut = false;
873
- const sdkQuery = query({ prompt, options: queryOptions });
957
+ let retryRequested = false;
958
+ let retryFailure: ClaudeAttemptFailure | undefined;
959
+ const sdkQuery = sdkQueryFactory({ prompt, options: queryOptions });
874
960
  ctx().activeQuery = sdkQuery;
875
961
 
876
962
  // 4. Capture context for abort handling (must be AFTER pushContext)
877
963
  const abortCtx = ctx();
964
+ // Failure metadata consumeQuery observed, surviving an iterator THROW (the
965
+ // .catch below reuses it instead of re-classifying — see C5 note there).
966
+ const attemptFailure: { failure?: ClaudeAttemptFailure } = {};
967
+ // A reentrant (subagent) query must never write the module-level shared
968
+ // session: its completion/failure handlers would overwrite the PARENT's
969
+ // record with the child's session id and cursor. A foreign-conversation
970
+ // one-shot (vstack#1001) has exactly the same non-claim on the record.
971
+ const persistSession = (next: SessionState | null): void => {
972
+ if (isReentrant || foreignContext) return;
973
+ setSharedSession(next && conversationFp ? { conversationFingerprint: conversationFp, ...next } : next);
974
+ };
975
+ const markRebuildForThisQuery = (opts: { forceRotate?: boolean } = {}): void => {
976
+ if (isReentrant || foreignContext) return;
977
+ markSessionForRebuild(opts);
978
+ };
979
+ // #967 invariant: a deferred (mid-query) user message may be dropped only
980
+ // LOUDLY — the cursor already advanced over it on the promise of replay.
981
+ // Callers that keep a session record after a non-empty drop must persist it
982
+ // with needsRebuild so the next turn re-imports the steers from Pi history.
983
+ const dropDeferredUserMessages = (site: string, undelivered?: DeferredUserMessage): DeferredUserMessage[] => {
984
+ const dropped = [...(undelivered !== undefined ? [undelivered] : []), ...abortCtx.deferredUserMessages];
985
+ abortCtx.deferredUserMessages = [];
986
+ if (dropped.length > 0) {
987
+ diagDump("deferred_user_messages_dropped", summarizeDroppedUserMessages(site, dropped));
988
+ }
989
+ return dropped;
990
+ };
991
+ let accountFailureRecorded = false;
992
+ const recordAttemptFailure = (failure: ClaudeAttemptFailure): void => {
993
+ // Rate-limit failures carry rateLimitInfo and were already recorded via
994
+ // router.recordRateLimit in consumeQuery.
995
+ if (
996
+ accountFailureRecorded || !account || !router || !failure.kind ||
997
+ failure.rateLimitInfo || wasAborted || options?.signal?.aborted
998
+ ) return;
999
+ safeRouterCall("recordFailure", () => router.recordFailure(account.profileId, failure.kind!, queryModel.id));
1000
+ accountFailureRecorded = true;
1001
+ };
878
1002
 
879
1003
  const requestAbort = () => {
880
1004
  // interrupt() asks the CLI to stop gracefully; close() kills it immediately.
@@ -882,6 +1006,39 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
882
1006
  void sdkQuery.interrupt().catch(() => {});
883
1007
  try { sdkQuery.close(); } catch {}
884
1008
  };
1009
+
1010
+ // Decide whether a classified failure may be replayed on the next profile.
1011
+ // Records the failure with the router either way (a post-output failure is
1012
+ // not replayable but the next prompt's routing should still avoid the
1013
+ // unhealthy account). The buffer's own committed flag backs up the context
1014
+ // flag in case the two ever disagree. Shared by the stream-idle watchdog and
1015
+ // the completion/error handlers below.
1016
+ const requestRotation = (failure: ClaudeAttemptFailure): boolean => {
1017
+ recordAttemptFailure(failure);
1018
+ const committed = abortCtx.committedOutput || attemptBuffer?.hasCommittedOutput === true;
1019
+ // Rotation retries re-enter streamClaudeAgentSdk from the outer promise
1020
+ // chain — an outermost-only path. A reentrant (subagent) query that fails
1021
+ // just fails; it must never queue a retry or burn a profile exclusion.
1022
+ const eligible = Boolean(!isReentrant && account && router && failure.kind && !committed && !wasAborted && !options?.signal?.aborted && rotationState.attempts < MAX_ROTATION_ATTEMPTS);
1023
+ debug("provider: account rotation decision", JSON.stringify({
1024
+ eligible,
1025
+ account: account?.label,
1026
+ kind: failure.kind,
1027
+ committedOutput: committed,
1028
+ wasAborted,
1029
+ signalAborted: options?.signal?.aborted === true,
1030
+ attempts: rotationState.attempts,
1031
+ }));
1032
+ if (!eligible || !account || !router || !failure.kind) return false;
1033
+ rotationState.excludedProfileIds.add(account.profileId);
1034
+ retryRequested = true;
1035
+ retryFailure = failure;
1036
+ attemptBuffer?.discard();
1037
+ abortCtx.currentPiStream = null;
1038
+ debug(`provider: rotating account after ${failure.kind}, from=${account.label}, attempt=${rotationState.attempts}`);
1039
+ return true;
1040
+ };
1041
+
885
1042
  const streamIdleTimeoutMs = streamIdleTimeoutMsFromEnv();
886
1043
  const streamIdleWatchdog = streamIdleTimeoutMs > 0
887
1044
  ? createStreamIdleWatchdog({
@@ -895,15 +1052,24 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
895
1052
  onTimeout: ({ idleMs, timeoutMs }) => {
896
1053
  if (streamIdleTimedOut || wasAborted || options?.signal?.aborted || abortCtx.activeQuery !== sdkQuery) return;
897
1054
  streamIdleTimedOut = true;
898
- abortCtx.deferredUserMessages = [];
899
- abortCtx.handledTerminalError = true;
900
- if (sharedSession) setSharedSession({ ...sharedSession, needsRebuild: true, forceRotate: true });
1055
+ dropDeferredUserMessages("stream-idle-timeout");
1056
+ markRebuildForThisQuery({ forceRotate: true });
901
1057
  const errorMessage = buildStreamIdleTimeoutErrorMessage(timeoutMs);
902
- debug("provider: stream idle timeout", `model=${model.id}`, `timeout=${timeoutMs}`, `idle=${idleMs}`);
1058
+ debug("provider: stream idle timeout", `model=${queryModel.id}`, `timeout=${timeoutMs}`, `idle=${idleMs}`);
1059
+ const idleFailure: ClaudeAttemptFailure = { kind: "network", message: errorMessage };
1060
+ // A managed attempt that went idle before ANY visible output can move
1061
+ // to the next profile instead of surfacing the timeout. The idle
1062
+ // specifics (needsRebuild/forceRotate, killing the child) stay here;
1063
+ // eligibility and retry bookkeeping are requestRotation's.
1064
+ if (requestRotation(idleFailure)) {
1065
+ requestAbort();
1066
+ return;
1067
+ }
1068
+ abortCtx.handledTerminalError = true;
903
1069
  emitRateLimitEvent({
904
1070
  idleMs,
905
- model: model.id,
906
- provider: PROVIDER_ID,
1071
+ model: queryModel.id,
1072
+ provider: queryModel.provider,
907
1073
  rateLimitType: "stream_idle",
908
1074
  reason: "Claude Code stream idle timeout",
909
1075
  retryAfterMs: STREAM_IDLE_BACKOFF_HINT_MS,
@@ -911,7 +1077,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
911
1077
  status: "rejected",
912
1078
  timeoutMs,
913
1079
  });
914
- piUI?.notify(`${RATE_LIMIT_TOKEN} Claude stream idle timeout after ${formatDurationShort(timeoutMs)} — retrying via rate-limit backoff`, "warning");
1080
+ safeNotify(`${RATE_LIMIT_TOKEN} Claude stream idle timeout after ${formatDurationShort(timeoutMs)} — retrying via rate-limit backoff`, "warning");
915
1081
  if (abortCtx.turnOutput) {
916
1082
  abortCtx.turnOutput.stopReason = "error";
917
1083
  abortCtx.turnOutput.errorMessage = errorMessage;
@@ -933,84 +1099,180 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
933
1099
  activeStreamIdleWatchdogs.set(abortCtx, streamIdleWatchdog);
934
1100
  streamIdleWatchdog.refresh();
935
1101
  }
936
- const onAbort = () => {
1102
+ // Runs in the ABORTER's async context (AbortSignal listeners do not inherit
1103
+ // AsyncLocalStorage), so re-enter this request's lane explicitly: the
1104
+ // mismatch report marks the shared record of whatever lane is current.
1105
+ const onAbort = () => runInRequestLane(laneId, () => {
937
1106
  wasAborted = true;
938
1107
  // Prevent stale deferred messages from being replayed by parent on pop
939
- abortCtx.deferredUserMessages = [];
940
- reportToolResultMismatch(abortCtx, "abort", cwd, { forceRotate: true });
1108
+ dropDeferredUserMessages("abort");
1109
+ reportToolResultMismatch(abortCtx, "abort", cwd, {
1110
+ expectedInterruption: true,
1111
+ forceRotate: true,
1112
+ });
941
1113
  const drained = drainPendingToolCalls(abortCtx, "abort");
942
1114
  if (drained > 0) debug(`provider: abort drained ${drained} waiting MCP handler(s) as errors`);
943
1115
  abortCtx.pendingResults.clear();
944
1116
  requestAbort();
945
- };
1117
+ });
946
1118
  if (options?.signal) {
947
1119
  if (options.signal.aborted) onAbort();
948
1120
  else options.signal.addEventListener("abort", onAbort, { once: true });
949
1121
  }
950
1122
 
951
- // Background consumer runs until query ends
952
- consumeQuery(sdkQuery, customToolNameToPi, model, cwd, bridgeConfig, () => wasAborted)
953
- .then(async ({ capturedSessionId }) => {
954
- debug(`provider: consumeQuery completed, stopReason=${ctx().turnOutput?.stopReason}, error=${ctx().turnOutput?.errorMessage}, aborted=${wasAborted}`);
1123
+ const surfaceFailure = (failure: ClaudeAttemptFailure, aborted = false): void => {
1124
+ attemptBuffer?.commit();
1125
+ if (failure.rateLimitInfo) {
1126
+ // Managed rate-limit that will NOT rotate — this is the single place its
1127
+ // event + toast are emitted (the legacy path emitted inline instead).
1128
+ const info = failure.rateLimitInfo;
1129
+ const resetAt = rateLimitResetFromInfo(info);
1130
+ const resetAtMs = rateLimitResetMs(info);
1131
+ emitRateLimitEvent({
1132
+ model: queryModel.id, provider: queryModel.provider, rateLimitType: rateLimitTypeFromInfo(info),
1133
+ reason: failure.message, resetAt,
1134
+ ...(Number.isFinite(resetAtMs) ? { resetAtMs } : {}),
1135
+ source: "claude-bridge", status: "rejected",
1136
+ });
1137
+ safeNotify(`${RATE_LIMIT_TOKEN} Claude ${failure.message} — resets ${formatResetTimestamp(resetAtMs ?? resetAt)}`, "warning");
1138
+ }
1139
+ if (abortCtx.turnOutput) {
1140
+ abortCtx.turnOutput.stopReason = aborted ? "aborted" : "error";
1141
+ abortCtx.turnOutput.errorMessage = failure.message;
1142
+ }
1143
+ abortCtx.currentPiStream?.push({ type: "error", reason: aborted ? "aborted" : "error", error: abortCtx.turnOutput! });
1144
+ abortCtx.currentPiStream?.end();
1145
+ abortCtx.currentPiStream = null;
1146
+ };
1147
+
1148
+ // Background consumer — runs until this attempt's query ends. Before any
1149
+ // visible output, a classified failure on a managed attempt is replayed once
1150
+ // on each remaining profile; after output/connector dispatch, replay is
1151
+ // forbidden and the failure surfaces.
1152
+ // The handlers below use the CAPTURED abortCtx, never the live ctx(): the two
1153
+ // only differ while a reentrant (subagent) context is pushed, and a parent
1154
+ // query CAN end in that window (abort, child process death throwing out of
1155
+ // the generator). Live-ctx handlers there mutated the subagent's turn state
1156
+ // and stream and skipped the parent's own teardown entirely.
1157
+ consumeQuery(sdkQuery, abortCtx, customToolNameToPi, queryModel, bridgeConfig, () => wasAborted, account, router, attemptFailure)
1158
+ .then(async ({ capturedSessionId, failure }) => {
1159
+ debug(`provider: consumeQuery completed, stopReason=${abortCtx.turnOutput?.stopReason}, failure=${failure?.kind ?? "none"}, aborted=${wasAborted}`);
955
1160
  if (streamIdleTimedOut) {
956
- abortCtx.deferredUserMessages = [];
957
- debug("provider: stream idle timeout already surfaced; skipping normal completion");
1161
+ dropDeferredUserMessages("stream-idle-timeout-completion");
1162
+ debug(`provider: stream idle timeout ${retryRequested ? "queued account rotation" : "already surfaced"}; skipping normal completion`);
958
1163
  return;
959
1164
  }
960
1165
 
961
1166
  // --- Abort detection in normal completion path ---
962
1167
  if (wasAborted || options?.signal?.aborted) {
963
- if (sharedSession) setSharedSession({ ...sharedSession, needsRebuild: true, forceRotate: true });
964
- ctx().deferredUserMessages = [];
1168
+ markRebuildForThisQuery({ forceRotate: true });
1169
+ dropDeferredUserMessages("abort-completion");
965
1170
  debug(`provider: abort detected, marked sharedSession needsRebuild + forceRotate`);
966
- if (ctx().turnOutput) {
967
- ctx().turnOutput.stopReason = "aborted";
968
- ctx().turnOutput.errorMessage = "Operation aborted";
1171
+ surfaceFailure({ message: "Operation aborted" }, true);
1172
+ return;
1173
+ }
1174
+
1175
+ // --- Failure held by consumeQuery ---
1176
+ if (failure) {
1177
+ if (requestRotation(failure)) return;
1178
+ // Not replayable (legacy, post-output, unclassified, or attempts
1179
+ // exhausted): surface an explicit error — unless the usage-limit path
1180
+ // already did — and persist the session record: the child session
1181
+ // advanced through this query, so dropping it would force a full
1182
+ // rebuild next turn. But NEVER run the deferred-replay loop from
1183
+ // here — surfacing ended the Pi stream, so a continuation query's
1184
+ // output would be invisible while its tool side effects still
1185
+ // execute. Deferred user input is dropped LOUDLY, and when steers
1186
+ // were dropped the record is marked needsRebuild: the cursor already
1187
+ // advanced over them on the promise of replay, so a plain REUSE next
1188
+ // turn would silently lose them forever (#967).
1189
+ if (!abortCtx.handledTerminalError) surfaceFailure(failure);
1190
+ const droppedSteers = dropDeferredUserMessages("terminal-failure");
1191
+ const activeSession = getSharedSession();
1192
+ const failedSessionId = capturedSessionId ?? activeSession?.sessionId;
1193
+ if (failedSessionId) {
1194
+ const cursor = Math.max(context.messages.length, abortCtx.latestCursor, activeSession?.cursor ?? 0);
1195
+ debug(`provider: terminal failure, persisting session=${failedSessionId.slice(0, 8)}, cursor=${cursor}, account=${account?.label ?? "legacy"}, droppedSteers=${droppedSteers.length}`);
1196
+ persistSession({ sessionId: failedSessionId, cursor, cwd, ...accountScope, ...(droppedSteers.length > 0 ? { needsRebuild: true } : {}) });
969
1197
  }
970
- ctx().currentPiStream?.push({ type: "error", reason: "aborted", error: ctx().turnOutput! });
971
- ctx().currentPiStream?.end();
972
- ctx().currentPiStream = null;
973
1198
  return;
974
1199
  }
975
1200
 
976
1201
  // --- Capture session ID ---
977
- const sessionId = capturedSessionId ?? sharedSession?.sessionId;
1202
+ const activeSession = getSharedSession();
1203
+ const sessionId = capturedSessionId ?? activeSession?.sessionId;
978
1204
  if (sessionId) {
979
- const cursor = Math.max(context.messages.length, ctx().latestCursor, sharedSession?.cursor ?? 0);
980
- debug(`provider: query done, session=${sessionId.slice(0, 8)}, cursor=${cursor}`);
981
- setSharedSession({ sessionId, cursor, cwd });
1205
+ const cursor = Math.max(context.messages.length, abortCtx.latestCursor, activeSession?.cursor ?? 0);
1206
+ debug(`provider: query done, session=${sessionId.slice(0, 8)}, cursor=${cursor}, account=${account?.label ?? "legacy"}`);
1207
+ // Fresh record on purpose: a transient mid-turn needsRebuild/forceRotate
1208
+ // must not survive a completed query and force a rebuild next turn.
1209
+ persistSession({ sessionId, cursor, cwd, ...accountScope });
982
1210
  }
1211
+ // The failure branch above returned, so reaching here means success.
1212
+ if (account && router) safeRouterCall("recordSuccess", () => router.recordSuccess(account.profileId, options?.sessionId));
983
1213
 
984
1214
  // --- Replay deferred user messages as continuation queries ---
985
1215
  // Only for outermost queries — reentrant (subagent) queries leave
986
1216
  // deferred messages for the parent to handle after it finishes.
987
1217
  try {
988
- while (ctx().deferredUserMessages.length > 0 && !isReentrant && !wasAborted) {
989
- const steerPrompt = ctx().deferredUserMessages.shift()!;
990
- debug(`provider: replaying deferred user message: ${steerPrompt.slice(0, 60)}`);
991
- ctx().resetTurnState(model);
992
- ctx().resetToolTracking();
993
-
994
- const resumeId = sharedSession?.sessionId;
1218
+ while (abortCtx.deferredUserMessages.length > 0 && !isReentrant && !wasAborted) {
1219
+ const steer = abortCtx.deferredUserMessages.shift()!;
1220
+ const steerPreview = (steer.text || "[image-only]").slice(0, 60);
1221
+ debug(`provider: replaying deferred user message: ${steerPreview}`);
1222
+ abortCtx.resetTurnState(queryModel);
1223
+ abortCtx.resetToolTracking();
1224
+
1225
+ // A foreign one-shot has no claim on the shared record: its steers
1226
+ // continue ITS OWN child session, never --resume the parent's.
1227
+ const resumeId = foreignContext ? capturedSessionId : getSharedSession()?.sessionId;
995
1228
  if (!resumeId) {
996
1229
  debug(`WARNING: no session to resume for deferred message, dropping`);
1230
+ // No record survives here (no session id), so the next turn
1231
+ // rebuilds from Pi history anyway — but the drop is still diagnosed.
1232
+ dropDeferredUserMessages("continuation-no-resume-id", steer);
997
1233
  break;
998
1234
  }
999
1235
 
1000
1236
  const contOptions = { ...queryOptions, resume: resumeId, ...makeCliDebugOptions("continuation") };
1001
- const contQuery = query({ prompt: steerPrompt, options: contOptions });
1002
- ctx().activeQuery = contQuery;
1237
+ // Runs carrying image blocks replay as blocks (wrapPromptStream) so
1238
+ // the images survive; text-only runs stay plain strings (vstack#993).
1239
+ const contQuery = sdkQueryFactory({ prompt: steer.blocks ? wrapPromptStream(steer.blocks) : steer.text, options: contOptions });
1240
+ abortCtx.activeQuery = contQuery;
1003
1241
 
1004
- debug(`provider: continuation query, model=${model.id}, resume=${resumeId.slice(0, 8)}, prompt=${steerPrompt.slice(0, 60)}`);
1242
+ debug(`provider: continuation query, model=${queryModel.id}, resume=${resumeId.slice(0, 8)}, account=${account?.label ?? "legacy"}, prompt=${steerPreview}`);
1005
1243
 
1006
1244
  try {
1007
- const { capturedSessionId: contSid } = await consumeQuery(contQuery, customToolNameToPi, model, cwd, bridgeConfig, () => wasAborted);
1008
- const sid = contSid ?? sharedSession?.sessionId;
1245
+ const continuation = await consumeQuery(contQuery, abortCtx, customToolNameToPi, queryModel, bridgeConfig, () => wasAborted, account, router);
1246
+ if (continuation.failure) {
1247
+ // Continuations never rotate: the original prompt already
1248
+ // committed on this account.
1249
+ recordAttemptFailure(continuation.failure);
1250
+ if (!abortCtx.handledTerminalError) surfaceFailure(continuation.failure);
1251
+ // The shifted steer may never have reached the child, and any
1252
+ // remaining ones certainly did not — the record must rebuild so
1253
+ // they re-import from Pi history (#967).
1254
+ if (dropDeferredUserMessages("continuation-failure", steer).length > 0) {
1255
+ markRebuildForThisQuery();
1256
+ }
1257
+ break;
1258
+ }
1259
+ const activeSession = getSharedSession();
1260
+ const sid = continuation.capturedSessionId ?? activeSession?.sessionId;
1009
1261
  if (sid) {
1010
- setSharedSession({ sessionId: sid, cursor: sharedSession?.cursor ?? 0, cwd });
1262
+ persistSession({ sessionId: sid, cursor: activeSession?.cursor ?? 0, cwd, ...accountScope });
1011
1263
  }
1012
1264
  } catch (contError) {
1013
1265
  debug(`provider: continuation query error:`, contError);
1266
+ const continuationFailure: ClaudeAttemptFailure = {
1267
+ kind: classifyClaudeFailure(contError),
1268
+ message: contError instanceof Error ? contError.message : String(contError),
1269
+ };
1270
+ recordAttemptFailure(continuationFailure);
1271
+ if (!abortCtx.handledTerminalError) surfaceFailure(continuationFailure);
1272
+ // Same #967 posture as the failure branch above.
1273
+ if (dropDeferredUserMessages("continuation-error", steer).length > 0) {
1274
+ markRebuildForThisQuery();
1275
+ }
1014
1276
  break;
1015
1277
  } finally {
1016
1278
  contQuery.close();
@@ -1018,158 +1280,100 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1018
1280
  }
1019
1281
  } finally {
1020
1282
  // Guarantees restoration even if contQuery() throws synchronously
1021
- ctx().activeQuery = sdkQuery;
1283
+ abortCtx.activeQuery = sdkQuery;
1022
1284
  }
1023
1285
 
1024
- finalizeCurrentStream(ctx().turnOutput?.stopReason);
1286
+ finalizeCurrentStream(abortCtx.turnOutput?.stopReason, abortCtx);
1025
1287
  })
1026
1288
  .catch((error) => {
1027
- debug(`provider: query error, model=${model.id}, aborted=${Boolean(options?.signal?.aborted)}, error=`, error);
1028
- const suppressDuplicateError = ctx().handledTerminalError || streamIdleTimedOut;
1029
- const openedExtraUsage = !suppressDuplicateError && isExtraUsageRequiredMessage(error) && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "query error");
1030
- if ((wasAborted || options?.signal?.aborted) && sharedSession) {
1031
- setSharedSession({ ...sharedSession, needsRebuild: true, forceRotate: true });
1032
- } else {
1033
- setSharedSession(null);
1289
+ debug(`provider: query error, model=${queryModel.id}, aborted=${Boolean(options?.signal?.aborted)}, error=`, error);
1290
+ const suppressDuplicateError = abortCtx.handledTerminalError || (streamIdleTimedOut && !retryRequested);
1291
+ if (wasAborted || options?.signal?.aborted) {
1292
+ markRebuildForThisQuery({ forceRotate: true });
1034
1293
  }
1035
- ctx().deferredUserMessages = [];
1036
- if (suppressDuplicateError) {
1037
- debug("provider: suppressing duplicate query error after terminal error was already emitted");
1038
- return;
1294
+ // #967: a record kept past this error with steers behind its cursor
1295
+ // must rebuild so they re-import from Pi history. (The non-abort
1296
+ // surface path below replaces the record with null, which rebuilds too.)
1297
+ if (dropDeferredUserMessages("query-error").length > 0) {
1298
+ markRebuildForThisQuery();
1039
1299
  }
1040
- if (ctx().turnOutput) {
1041
- ctx().turnOutput.stopReason = options?.signal?.aborted ? "aborted" : "error";
1042
- ctx().turnOutput.errorMessage = `${error instanceof Error ? error.message : String(error)}${openedExtraUsage ? "\n\nOpened Claude Code /extra-usage helper. Complete billing/admin flow in the browser, then retry the prompt." : ""}`;
1300
+ if (suppressDuplicateError || retryRequested) {
1301
+ debug("provider: suppressing duplicate query error after terminal handling");
1302
+ return;
1043
1303
  }
1044
- ctx().currentPiStream?.push({ type: "error", reason: (ctx().turnOutput?.stopReason ?? "error") as "aborted" | "error", error: ctx().turnOutput! });
1045
- ctx().currentPiStream?.end();
1046
- ctx().currentPiStream = null;
1304
+ // Prefer the failure metadata consumeQuery held before the throw: a
1305
+ // rejected rate_limit_event followed by the iterator throwing was
1306
+ // re-classified here WITHOUT its rateLimitInfo, so recordAttemptFailure
1307
+ // recorded a second failure on top of the recordRateLimit the event
1308
+ // already taught the router — a double-counted cooldown.
1309
+ const failure: ClaudeAttemptFailure = attemptFailure.failure?.rateLimitInfo
1310
+ ? attemptFailure.failure
1311
+ : {
1312
+ kind: classifyClaudeFailure(error),
1313
+ message: error instanceof Error ? error.message : String(error),
1314
+ };
1315
+ if (requestRotation(failure)) return;
1316
+ if (!wasAborted && !options?.signal?.aborted) persistSession(null);
1317
+ surfaceFailure(failure, Boolean(options?.signal?.aborted));
1047
1318
  })
1048
1319
  .finally(() => {
1049
1320
  streamIdleWatchdog?.dispose();
1050
1321
  activeStreamIdleWatchdogs.delete(abortCtx);
1051
1322
  if (options?.signal) options.signal.removeEventListener("abort", onAbort);
1052
- if (ctx().activeQuery === sdkQuery) {
1053
- const cause = toolCallDrainCause({ wasAborted, signalAborted: options?.signal?.aborted, streamIdleTimedOut });
1054
- reportToolResultMismatch(ctx(), "query teardown", cwd, { forceRotate: cause !== "query-end" });
1055
- // Drain pending handlers for this query as errors naming the cause —
1056
- // their results are never coming.
1057
- const drained = drainPendingToolCalls(ctx(), cause);
1058
- if (drained > 0) debug(`provider: query teardown drained ${drained} waiting MCP handler(s) as errors (cause=${cause})`);
1059
- ctx().pendingResults.clear();
1060
-
1061
- if (isReentrant) {
1062
- popContext(); // merges deferred messages and restores parent
1063
- } else {
1064
- ctx().activeQuery = null;
1323
+ const cause = toolCallDrainCause({ wasAborted, signalAborted: options?.signal?.aborted, streamIdleTimedOut });
1324
+ teardownQuery(abortCtx, sdkQuery, cause, cwd, isReentrant);
1325
+ sdkQuery.close();
1326
+ })
1327
+ .then(async () => {
1328
+ // --- Account retry re-entry ---
1329
+ // Runs AFTER teardown so the failed attempt's query state is fully
1330
+ // released. The recursive call re-enters the fresh-query path with the
1331
+ // rotation state carrying the excluded profiles; its events forward
1332
+ // into this attempt's still-unstarted Pi stream.
1333
+ if (!retryRequested) return;
1334
+ if (wasAborted || options?.signal?.aborted) {
1335
+ // An abort landed AFTER rotation was queued: requestRotation already
1336
+ // discarded the attempt buffer and nulled currentPiStream, and only
1337
+ // the retry loop below would have ended the outer stream. Skipping
1338
+ // the retry without terminating here left the consumer hanging on a
1339
+ // stream that never ends.
1340
+ debug("provider: abort after queued account retry — terminating stream without retrying");
1341
+ if (abortCtx.turnOutput) {
1342
+ abortCtx.turnOutput.stopReason = "aborted";
1343
+ abortCtx.turnOutput.errorMessage = "Operation aborted";
1065
1344
  }
1345
+ stream.push({ type: "error", reason: "aborted", error: abortCtx.turnOutput! });
1346
+ stream.end();
1347
+ return;
1066
1348
  }
1067
- sdkQuery.close();
1068
- });
1349
+ debug(`provider: starting account retry after ${retryFailure?.kind ?? "failure"}; excluded=${[...rotationState.excludedProfileIds].join(",")}`);
1350
+ const retryStream = streamClaudeAgentSdk(model, context, {
1351
+ ...(options ?? {}),
1352
+ [ROTATION_STATE_KEY]: rotationState,
1353
+ } as BridgeStreamOptions);
1354
+ // End exactly once per outcome (VST-53). Ending in a `finally` ran on
1355
+ // the throw path too, BEFORE the .catch below could push its error
1356
+ // event — and EventStream.push is a silent no-op after end, so a failed
1357
+ // rotation ended the turn with no error event at all. Success ends
1358
+ // here; every throw ends in the .catch, after the error is pushed.
1359
+ for await (const event of retryStream) stream.push(event);
1360
+ stream.end();
1361
+ })
1362
+ .catch((error) => {
1363
+ debug("provider: account retry pipeline failed:", error);
1364
+ if (abortCtx.turnOutput) {
1365
+ abortCtx.turnOutput.stopReason = "error";
1366
+ abortCtx.turnOutput.errorMessage = error instanceof Error ? error.message : String(error);
1367
+ }
1368
+ stream.push({ type: "error", reason: "error", error: abortCtx.turnOutput! });
1369
+ stream.end();
1370
+ })
1371
+ // After teardown and any retry pipeline: nothing else reads this lane.
1372
+ .finally(releaseEphemeralLane);
1069
1373
 
1070
1374
  return stream;
1071
1375
  }
1072
1376
 
1073
- function commandCwd(ctx: unknown): string {
1074
- const value = (ctx as { cwd?: unknown })?.cwd;
1075
- return typeof value === "string" && value.length > 0 ? value : process.cwd();
1076
- }
1077
-
1078
- async function tryOpenExtensionManagerSettings(ctx: { ui: ExtensionUIContext }): Promise<boolean> {
1079
- const host = globalThis as unknown as Record<PropertyKey, unknown>;
1080
- const openQuickSettings = host[Symbol.for("vstack.pi.extension-manager.open-quick-settings")];
1081
- if (typeof openQuickSettings !== "function") return false;
1082
- try {
1083
- await (openQuickSettings as (ctx: unknown, hint?: string) => Promise<void>)(ctx, "@vanillagreen/pi-claude-bridge");
1084
- return true;
1085
- } catch {
1086
- return false;
1087
- }
1088
- }
1089
-
1090
- function showBridgeStatus(ctx: { ui: ExtensionUIContext; cwd?: string }): void {
1091
- const config = loadConfig(commandCwd(ctx));
1092
- ctx.ui.notify([
1093
- `Claude bridge: ${config.enabled === false ? "disabled" : "enabled"}`,
1094
- `Extra usage auto-helper: ${extraUsageAllowed(config) ? "on" : "off"} (settings)`,
1095
- `Use /claude-bridge:extra to run Claude Code /extra-usage now.`,
1096
- ].join("\n"), "info");
1097
- }
1098
-
1099
- // Read a credential file, treating any read error as "absent" — a missing or
1100
- // unreadable candidate must fall through to the next one, not abort resolution.
1101
- function readCredentialFile(path: string): string | undefined {
1102
- try {
1103
- return nodeReadFileSync(path, "utf8");
1104
- } catch {
1105
- return undefined;
1106
- }
1107
- }
1108
-
1109
- // Deterministic connector enumeration for the host app (vstack#838). Reports the
1110
- // failure reason rather than an empty list, so "no connectors" and "could not
1111
- // check" stay distinguishable.
1112
- async function reportConnectorInventory(ctx: { ui: ExtensionUIContext }): Promise<void> {
1113
- const credentials = resolveClaudeOAuth(readCredentialFile);
1114
- if (!credentials) {
1115
- ctx.ui.notify("Claude bridge: no Claude OAuth credentials found — cannot enumerate connectors.", "error");
1116
- return;
1117
- }
1118
- const inventory = await listAccountConnectors({ credentials });
1119
- if (!inventory.ok) {
1120
- ctx.ui.notify(`Claude bridge: connector enumeration failed — ${inventory.reason}`, "error");
1121
- return;
1122
- }
1123
- if (inventory.connectors.length === 0) {
1124
- ctx.ui.notify("Claude bridge: this account has no connectors installed.", "info");
1125
- return;
1126
- }
1127
- const names = inventory.connectors.map((c) => c.name).join(", ");
1128
- ctx.ui.notify(`Claude bridge: ${inventory.connectors.length} connector(s) installed — ${names}`, "info");
1129
- }
1130
-
1131
- function registerBridgeCommands(pi: ExtensionAPI): void {
1132
- const guard = pi as unknown as Record<PropertyKey, unknown>;
1133
- if (guard[COMMANDS_REGISTERED_KEY]) return;
1134
- guard[COMMANDS_REGISTERED_KEY] = true;
1135
-
1136
- const runExtraUsage = async (ctx: { ui: ExtensionUIContext; cwd?: string }) => {
1137
- const cwd = commandCwd(ctx);
1138
- if (extraUsageHelperInFlight) {
1139
- ctx.ui.notify("Claude extra usage helper already running.", "info");
1140
- await extraUsageHelperInFlight.catch(() => undefined);
1141
- return;
1142
- }
1143
- try {
1144
- ctx.ui.notify("Claude extra usage helper starting…", "info");
1145
- extraUsageHelperInFlight = runExtraUsageHelper(cwd)
1146
- .finally(() => { extraUsageHelperInFlight = null; });
1147
- const message = await extraUsageHelperInFlight;
1148
- ctx.ui.notify(`Claude extra usage helper: ${message}`, "info");
1149
- } catch (error) {
1150
- const message = error instanceof Error ? error.message : String(error);
1151
- ctx.ui.notify(`Claude extra usage helper failed: ${message}`, "error");
1152
- }
1153
- };
1154
-
1155
- pi.registerCommand("claude-bridge", {
1156
- description: "Open Claude bridge settings/status",
1157
- handler: async (args: string, ctx) => {
1158
- if (args.trim()) ctx.ui.notify("Unknown /claude-bridge argument. Use /claude-bridge:extra to run Claude Code /extra-usage.", "warning");
1159
- if (await tryOpenExtensionManagerSettings(ctx)) return;
1160
- showBridgeStatus(ctx);
1161
- },
1162
- });
1163
- pi.registerCommand("claude-bridge:extra", {
1164
- description: "Run Claude Code /extra-usage through claude-bridge",
1165
- handler: async (_args: string, ctx) => runExtraUsage(ctx),
1166
- });
1167
- pi.registerCommand("claude-bridge:connectors", {
1168
- description: "List the Claude account's installed claude.ai connectors",
1169
- handler: async (_args: string, ctx) => reportConnectorInventory(ctx),
1170
- });
1171
- }
1172
-
1173
1377
  // --- Extension registration ---
1174
1378
 
1175
1379
  export default function (pi: ExtensionAPI) {
@@ -1179,22 +1383,35 @@ export default function (pi: ExtensionAPI) {
1179
1383
 
1180
1384
  const config = loadConfig(process.cwd());
1181
1385
  debug("loadConfig:", JSON.stringify(config));
1386
+ // Registered before the disabled early return: a bridge switched off by
1387
+ // claude-bridge.json is exactly when the settings editor has to show where
1388
+ // that value came from.
1389
+ registerExternalConfigResolver();
1182
1390
  registerBridgeCommands(pi);
1183
1391
  if (config.enabled === false) {
1184
1392
  debug("provider: disabled by configuration");
1185
1393
  return;
1186
1394
  }
1395
+ // Publish the reciprocal account-host service (a local /usage probe) for the
1396
+ // companion account manager. Primary instance only — a subagent reload must
1397
+ // not swap the owner from under an in-flight probe.
1398
+ if (claimPrimaryInstance()) {
1399
+ const host = globalThis as Record<symbol, any>;
1400
+ host[CLAUDE_BRIDGE_ACCOUNT_HOST_SYMBOL] = BRIDGE_ACCOUNT_HOST;
1401
+ }
1187
1402
 
1188
1403
  // Reset shared (Claude) conversation state on pi session lifecycle events.
1189
1404
  // Registration tokens are managed separately by applyProviderRegistration
1190
1405
  // (load / session_start / pre-spawn) and releaseProviderTokens (shutdown), so
1191
1406
  // a mid-session credential flip is handled while token ownership is intact.
1192
1407
  const clearSession = (event: string) => {
1193
- debug(`${event}: clearing session ${sharedSession?.sessionId?.slice(0, 8) ?? "none"}`);
1408
+ const activeSession = getSharedSession();
1409
+ debug(`${event}: clearing session ${activeSession?.sessionId?.slice(0, 8) ?? "none"}`);
1194
1410
  setSharedSession(null);
1195
1411
  };
1196
1412
 
1197
- pi.on("session_start", (event, ctx) => {
1413
+ pi.on("session_start", (event, ctx) => runInRequestLane(ctx.sessionManager.getSessionId(), () => {
1414
+ recordStartedLane(ctx.sessionManager, ctx.sessionManager.getSessionId());
1198
1415
  recordProjectTrust(ctx);
1199
1416
  setPiUI(ctx.ui);
1200
1417
  if (event.reason === "new" || event.reason === "resume" || event.reason === "fork") {
@@ -1208,15 +1425,21 @@ export default function (pi: ExtensionAPI) {
1208
1425
  // Live availability flip: re-evaluate credential presence every
1209
1426
  // session_start so login/logout since load is reflected without /reload.
1210
1427
  applyProviderRegistration(`session_start:${event.reason}`);
1428
+ }));
1429
+ pi.on("session_shutdown", (_event, ctx) => {
1430
+ const sessionId = takeStartedLane(ctx.sessionManager) ?? ctx.sessionManager.getSessionId();
1431
+ runInRequestLane(sessionId, () => {
1432
+ cancelScheduledSessionPersistence(ctx.sessionManager);
1433
+ clearSession("session_shutdown");
1434
+ releaseProviderTokens("session_shutdown");
1435
+ });
1436
+ deleteSharedSessionLane(sessionId);
1437
+ deleteQueryLane(sessionId);
1211
1438
  });
1212
- pi.on("session_shutdown", () => {
1213
- clearSession("session_shutdown");
1214
- releaseProviderTokens("session_shutdown");
1215
- });
1216
- pi.on("message_end", (event, ctx) => {
1439
+ pi.on("message_end", (event, ctx) => runInRequestLane(ctx.sessionManager.getSessionId(), () => {
1217
1440
  const message = (event as { message?: AssistantMessage }).message;
1218
1441
  if (message?.role === "assistant" && message.provider === PROVIDER_ID) schedulePersistSharedSession(ctx);
1219
- });
1442
+ }));
1220
1443
 
1221
1444
  // pi /compact and session-tree navigation (rewind / fork-at-point /
1222
1445
  // branch switch) both mutate pi's messages array out from under the
@@ -1226,23 +1449,25 @@ export default function (pi: ExtensionAPI) {
1226
1449
  // triggers CC's autocompact-thrashing guard (issue #8). Force the next
1227
1450
  // call down the REBUILD path so CC sees the current history.
1228
1451
  const markRebuild = (event: string) => {
1452
+ const activeSession = getSharedSession();
1229
1453
  if (ctx().activeQuery) {
1230
- reportToolResultMismatch(ctx(), event, sharedSession?.cwd ?? process.cwd());
1454
+ reportToolResultMismatch(ctx(), event, activeSession?.cwd ?? process.cwd());
1231
1455
  }
1232
- if (sharedSession) {
1233
- debug(`${event}: marking needsRebuild on session ${sharedSession.sessionId.slice(0, 8)}`);
1234
- setSharedSession({ ...sharedSession, needsRebuild: true });
1456
+ if (activeSession) {
1457
+ debug(`${event}: marking needsRebuild on session ${activeSession.sessionId.slice(0, 8)}`);
1458
+ markSessionForRebuild();
1235
1459
  }
1236
1460
  };
1237
- pi.on("session_compact", () => markRebuild("session_compact"));
1238
- pi.on("session_tree", () => markRebuild("session_tree"));
1461
+ pi.on("session_compact", (_event, ctx) => runInRequestLane(ctx.sessionManager.getSessionId(), () => markRebuild("session_compact")));
1462
+ pi.on("session_tree", (_event, ctx) => runInRequestLane(ctx.sessionManager.getSessionId(), () => markRebuild("session_tree")));
1239
1463
 
1240
1464
  // --- Provider ---
1241
1465
  //
1242
- // Register the provider ONLY when real Claude credentials are present, so
1243
- // claude-bridge models are never advertised as available/selectable when a
1244
- // request would fail at spawn time (pi's ModelRegistry.hasConfiguredAuth()
1245
- // treats the dummy apiKey as "configured", so the gate must live here).
1466
+ // Native registration (pi >=0.81): register unconditionally; the provider's
1467
+ // own auth check/resolve report whether Claude credentials exist, so pi
1468
+ // hides claude-bridge models while no account is connected and shows them
1469
+ // when one appears. session_start and pre-spawn re-upsert the provider to
1470
+ // force pi's availability recompute at those boundaries.
1246
1471
  //
1247
1472
  // applyProviderRegistration also claims the primary-instance token (first
1248
1473
  // load wins) and enforces the multi-instance guard: a non-primary subagent