@vanillagreen/pi-claude-bridge 2.0.0 → 4.0.0

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,23 +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, toolCallDrainCause } from "./query-state.js";
10
+ import { QueryContext, ctx, deleteQueryLane, drainPendingToolCalls, drainStrandedToolCalls, popContext, stackDepth, pushContext, summarizeDroppedUserMessages, takeQueuedOrParkedResult, toolCallDrainCause, type DeferredUserMessage } from "./query-state.js";
11
11
  import { teardownQuery } from "./query-teardown.js";
12
- import { loadConfig, normalizeEffortLevel, recordProjectTrust, type Config } from "./config.js";
12
+ import { loadConfig, recordProjectTrust, registerExternalConfigResolver } from "./config.js";
13
13
  import { hasClaudeCredentials } from "./auth-presence.js";
14
14
  import { NATIVE_PROVIDER_UNSUPPORTED_MESSAGE, buildNativeProvider, supportsNativeProvider } from "./native-provider.js";
15
- import { extractAgentsAppend } from "./agents-md.js";
16
- import { buildPromptContextAppend } from "./prompt-context.js";
17
15
  import { jsonSchemaToZodShape } from "./typebox-to-zod.js";
18
- import { readFileSync as nodeReadFileSync } from "node:fs";
19
16
  import { resolveGetModels } from "./pi-ai-compat.js";
20
- import { listAccountConnectors, resolveClaudeOAuth } from "./connector-inventory.js";
21
17
  // Re-exported from the extension entry point ON PURPOSE. Consuming apps
22
18
  // regenerate their vendored package.json with a CLOSED exports map
23
19
  // ({".": "./bundle/index.js"}), which makes Node reject BOTH a subpath import
@@ -38,31 +34,70 @@ export {
38
34
  type ConnectorEntry,
39
35
  type ConnectorInventory,
40
36
  } from "./connector-inventory.js";
41
- export { connectorCachePath, connectorCacheScopeKey, readCachedConnectors, writeCachedConnectors } from "./connector-cache.js";
37
+ export { connectorCachePath, connectorCacheScopeKey, readCachedConnectors, scopeKeyFor, writeCachedConnectors } from "./connector-cache.js";
38
+ export { connectorServersSnapshot, primeConnectorServers } from "./connector-runtime.js";
42
39
  import { debug, diagDump, makeCliDebugOptions, moduleInstanceId } from "./debug.js";
43
- import { preflightClaudeExecutable, resolveClaudeExecutable, spawnClaudeCodeWithDiagnostics } from "./claude-executable.js";
44
- import { appendIntegrityEntry, argKeys, extensionApi, piUI, reportToolResultMismatch, safeNotify, safeToolCallSummary, setExtensionApi, setPiUI, setSharedSession, sharedSession } from "./bridge-state.js";
45
- import { connectorMcpServers, connectorQueryOptions, connectorWriteModeFor, connectorsEnabledFor, isChildExecutedTool } from "./connectors.js";
46
- import { readCachedConnectors, writeCachedConnectors } from "./connector-cache.js";
47
- 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";
48
45
  import { STREAM_IDLE_BACKOFF_HINT_MS, activeStreamIdleWatchdogs, buildStreamIdleTimeoutErrorMessage, createStreamIdleWatchdog, formatDurationShort, streamIdleTimeoutMsFromEnv } from "./stream-idle-watchdog.js";
49
- import { RATE_LIMIT_AUTO_RESUME_EVENT, RATE_LIMIT_TOKEN, formatAllowedRateLimitWarning, formatResetTimestamp, isExtraUsageRequiredMessage, isUsageLimitMessage, resetTimestampMs, uniqueNonEmptyLines } from "./rate-limit.js";
46
+ import { RATE_LIMIT_TOKEN, formatResetTimestamp } from "./rate-limit.js";
50
47
  import { mapToolArgs } from "./tool-mapping.js";
51
- import { ensureTurnStarted, finalizeCurrentStream, finalizeToolUseTurnFromMcpInvocation, noteChildExecutedToolResults, processAssistantMessage, processStreamEvent, scheduleToolUseTurnEnd, 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";
52
67
 
53
68
  // Re-exports: the module decomposition must not change the bundle entry's
54
69
  // public surface — unit tests and downstream consumers import these from
55
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";
56
74
  export { classifyClaudeExecutableBytes, preflightClaudeExecutable, resolveClaudeExecutable, spawnClaudeCodeWithDiagnostics, wrapClaudeSpawnErrorForSdk, type ClaudeExecutableFileType, type ClaudeExecutablePreflightResult } from "./claude-executable.js";
57
75
  export { __testGetBridgeIntegrityState, __testSetBridgeIntegrityState, INTEGRITY_CUSTOM_TYPE, appendIntegrityEntry, reportToolResultMismatch } from "./bridge-state.js";
58
76
  export { CONNECTOR_CALL_CUSTOM_TYPE, connectorResultByteSize, flushConnectorCallAudit, recordConnectorCallResult, setConnectorCallAuditSink, type ConnectorCallAuditData, type ConnectorCallAuditSink, type ConnectorCallOutcome } from "./connector-audit.js";
59
- export { CLAUDE_AI_CONNECTOR_TOOL_PATTERNS, connectorMcpServers, connectorDeclarationsDisabled, CLAUDE_BRIDGE_TOOL_ISOLATION, CONNECTOR_DISCOVERY_TOOLS, CONNECTOR_WRITE_TOOLS, DISALLOWED_BUILTIN_TOOLS, connectorQueryOptions, connectorWriteDenyHook, connectorWriteModeFor, connectorWriteModeFromEnv, connectorsEnabledFor, connectorsEnabledFromEnv, isChildExecutedTool, isConnectorWriteTool, toolIsolationForQuery } from "./connectors.js";
60
- export { restoreSharedSessionFromPi, shouldRestorePersistedBridgeEntry } from "./session-persistence.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";
61
79
  export { NATIVE_PROVIDER_UNSUPPORTED_MESSAGE, buildNativeProvider, claudeAuthSourceLabel, supportsNativeProvider } from "./native-provider.js";
62
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";
63
- export { ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD, formatAllowedRateLimitWarning, formatResetTimestamp, isExtraUsageRequiredMessage, isUsageLimitMessage, normalizeRateLimitUtilization, resetTimestampMs, uniqueNonEmptyLines } from "./rate-limit.js";
81
+ export { ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD, formatAllowedRateLimitWarning, formatResetTimestamp, isUsageLimitMessage, normalizeRateLimitUtilization, resetTimestampMs, uniqueNonEmptyLines } from "./rate-limit.js";
64
82
  export { mapToolName } from "./tool-mapping.js";
65
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";
66
101
 
67
102
  // Compat (#2): use factory if available (pi-ai ≥0.66), else fall back to constructor (gsd-pi etc.)
68
103
  const _piAi = piAi as any;
@@ -100,112 +135,29 @@ const newAssistantMessageEventStream: () => AssistantMessageEventStream =
100
135
  // native (pi >=0.81) upsert flow.
101
136
  const PRIMARY_INSTANCE_KEY = Symbol.for("claude-bridge:primaryInstance");
102
137
  const ACTIVE_STREAM_SIMPLE_KEY = Symbol.for("claude-bridge:activeStreamSimple");
103
- const COMMANDS_REGISTERED_KEY = Symbol.for("claude-bridge:commandsRegistered");
104
-
105
- // MODELS is buildModels(getModels("anthropic")) — projection kept in models.js.
106
- const MODELS = buildModels(getModels("anthropic"));
107
-
108
- let extraUsageHelperInFlight: Promise<string> | null = null;
109
-
110
- function emitRateLimitEvent(payload: Record<string, unknown>): void {
111
- try {
112
- extensionApi?.events?.emit?.(RATE_LIMIT_AUTO_RESUME_EVENT, payload);
113
- } catch {
114
- // Cross-extension broker is best-effort only.
115
- }
116
- }
117
-
118
- function extraUsageAllowed(config: Config): boolean {
119
- return config.provider?.allowExtraUsage === true;
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;
120
151
  }
121
152
 
122
- // The fastMode setting silently no-ops when Claude Code declines fast mode.
123
- // Surface the typed fast_mode_disabled_reason (SDK 0.3.219+) once per distinct
124
- // reason so an enabled-but-inert setting explains itself instead of looking
125
- // broken. Module-level dedup: the same reason repeats on every init message.
126
- let lastFastModeDisabledNoticeReason: string | null = null;
127
-
128
- const FAST_MODE_DISABLED_REASON_TEXT: Record<string, string> = {
129
- disabled_by_env: "disabled by an environment variable",
130
- extra_usage_disabled: "extra usage is disabled for this account",
131
- free: "not available on the free plan",
132
- model_not_allowed: "not available for this model",
133
- network_error: "the eligibility check hit a network error",
134
- not_first_party: "not available for this account type",
135
- preference: "disabled by a Claude Code preference",
136
- sdk_opt_in_required: "the SDK opt-in is missing",
137
- unknown: "unavailable for an unknown reason",
153
+ type BridgeStreamOptions = SimpleStreamOptions & {
154
+ [ROTATION_STATE_KEY]?: RotationRequestState;
138
155
  };
139
156
 
140
- function noteFastModeDisabledReason(message: unknown, bridgeConfig: Config): void {
141
- if (bridgeConfig.provider?.fastMode !== true) return;
142
- const reason = (message as { fast_mode_disabled_reason?: unknown }).fast_mode_disabled_reason;
143
- // "pending" means the CLI is still deciding — not a verdict worth announcing.
144
- if (typeof reason !== "string" || reason === "pending") return;
145
- if (reason === lastFastModeDisabledNoticeReason) return;
146
- lastFastModeDisabledNoticeReason = reason;
147
- const text = FAST_MODE_DISABLED_REASON_TEXT[reason] ?? `unavailable (${reason})`;
148
- safeNotify(`Claude bridge: fast mode is enabled in settings but Claude Code declined it — ${text}.`, "warning");
149
- }
150
-
151
- function sdkTextFromMessage(message: SDKMessage): string | undefined {
152
- if (message.type === "result") return (message as any).result;
153
- if (message.type === "assistant") {
154
- const content = (message as any).message?.content;
155
- if (!Array.isArray(content)) return undefined;
156
- return content
157
- .map((block) => block?.type === "text" && typeof block.text === "string" ? block.text : "")
158
- .filter(Boolean)
159
- .join("\n");
160
- }
161
- return undefined;
162
- }
157
+ // MODELS is buildModels(getModels("anthropic")) projection kept in models.js.
158
+ const MODELS = buildModels(getModels("anthropic"));
163
159
 
164
- async function runExtraUsageHelper(cwd: string, config = loadConfig(cwd)): Promise<string> {
165
- const providerSettings = config.provider ?? {};
166
- const claudeExecutable = resolveClaudeExecutable(providerSettings.pathToClaudeCodeExecutable);
167
- if (claudeExecutable) preflightClaudeExecutable(claudeExecutable, cwd);
168
-
169
- const helperQuery = query({
170
- prompt: "/extra-usage",
171
- options: {
172
- cwd,
173
- env: { ...process.env, ENABLE_CLAUDEAI_MCP_SERVERS: "0", DISABLE_AUTO_COMPACT: "1" },
174
- maxTurns: 1,
175
- ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
176
- spawnClaudeCodeProcess: spawnClaudeCodeWithDiagnostics,
177
- ...makeCliDebugOptions("extra-usage"),
178
- },
179
- });
180
- const outputs: string[] = [];
181
- try {
182
- for await (const message of helperQuery) {
183
- const text = sdkTextFromMessage(message)?.trim();
184
- if (text && outputs[outputs.length - 1] !== text) outputs.push(text);
185
- }
186
- } finally {
187
- helperQuery.close();
188
- }
189
- return outputs.join("\n").trim() || "Claude Code /extra-usage completed.";
190
- }
191
160
 
192
- function launchExtraUsageHelperIfAllowed(cwd: string, config: Config, reason: string): boolean {
193
- if (!extraUsageAllowed(config)) return false;
194
- if (extraUsageHelperInFlight) return true;
195
- extraUsageHelperInFlight = runExtraUsageHelper(cwd, config)
196
- .then((message) => {
197
- piUI?.notify(`Claude extra usage helper: ${message}`, "info");
198
- return message;
199
- })
200
- .catch((error) => {
201
- const message = error instanceof Error ? error.message : String(error);
202
- piUI?.notify(`Claude extra usage helper failed after ${reason}: ${message}`, "error");
203
- throw error;
204
- })
205
- .finally(() => { extraUsageHelperInFlight = null; });
206
- void extraUsageHelperInFlight.catch(() => {});
207
- return true;
208
- }
209
161
 
210
162
  // Pi doesn't pass tool results directly — it appends them to the context and calls
211
163
  // the provider again. Thin wrapper over extract-tool-results.js that adds per-turn
@@ -220,49 +172,99 @@ function extractAllToolResults(context: Context): McpResult[] {
220
172
  return results;
221
173
  }
222
174
 
223
- /** 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. */
224
184
  function extractUserPrompt(messages: Context["messages"]): string | null {
225
- const last = messages[messages.length - 1];
226
- if (!last || last.role !== "user") return null;
227
- if (typeof last.content === "string") return last.content;
228
- 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");
229
189
  }
230
190
 
231
- /** Extract the last user message as ContentBlockParam[] (preserving images).
232
- * 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). */
233
194
  function extractUserPromptBlocks(messages: Context["messages"]): ContentBlockParam[] | null {
234
- const last = messages[messages.length - 1];
235
- if (!last || last.role !== "user") return null;
236
- if (typeof last.content === "string") {
237
- debug(`extractUserPromptBlocks: content is string (length=${last.content.length})`);
238
- return null;
239
- }
240
- if (!Array.isArray(last.content)) {
241
- debug(`extractUserPromptBlocks: content is ${typeof last.content}`);
242
- return null;
243
- }
244
- 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
+
245
197
  let hasImage = false;
246
198
  const blocks: ContentBlockParam[] = [];
247
- for (const block of last.content) {
248
- if (block.type === "text" && block.text) {
249
- blocks.push({ type: "text", text: block.text });
250
- } else if (block.type === "image") {
251
- debug(`image block: mimeType=${(block as any).mimeType}, data length=${((block as any).data ?? "").length}, keys=${Object.keys(block).join(",")}`);
252
- if (!(block as any).data || !(block as any).mimeType) {
253
- debug(`image block missing data or mimeType, skipping`);
254
- 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
+ });
255
225
  }
256
- hasImage = true;
257
- blocks.push({
258
- type: "image",
259
- source: { type: "base64", media_type: block.mimeType as Base64ImageSource["media_type"], data: block.data },
260
- });
261
226
  }
262
227
  }
263
228
  return hasImage ? blocks : null;
264
229
  }
265
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 (kendex#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 (kendex#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 (kendex#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
+
266
268
  async function* wrapPromptStream(blocks: ContentBlockParam[]): AsyncIterable<SDKUserMessage> {
267
269
  yield {
268
270
  type: "user",
@@ -376,12 +378,11 @@ function buildMcpServers(tools: Tool[], queryCtx: QueryContext): Record<string,
376
378
  } else if (claim.match !== "tool-args" || claim.ambiguous) {
377
379
  debug(`mcp handler: ${tool.name} [${toolCallId}] claimed by ${claim.match}${claim.ambiguous ? " (ambiguous)" : ""}`);
378
380
  }
379
- if (toolCallId && queryCtx.pendingResults.has(toolCallId)) {
380
- const result = queryCtx.pendingResults.get(toolCallId)!;
381
- queryCtx.pendingResults.delete(toolCallId);
381
+ const earlyResult = toolCallId ? takeQueuedOrParkedResult(queryCtx, toolCallId) : undefined;
382
+ if (earlyResult !== undefined) {
382
383
  queryCtx.markToolResultResolved(toolCallId);
383
- debug(`mcp handler: ${tool.name} [${toolCallId}] → resolved from queue (${queryCtx.pendingResults.size} remaining)`);
384
- 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;
385
386
  }
386
387
  debug(`mcp handler: ${tool.name} [${toolCallId}] → waiting`);
387
388
  // Don't end the pi turn here — message_delta (real output tokens) and
@@ -395,6 +396,8 @@ function buildMcpServers(tools: Tool[], queryCtx: QueryContext): Record<string,
395
396
  return new Promise<McpResult>((resolve) => {
396
397
  queryCtx.pendingToolCalls.set(toolCallId, {
397
398
  toolName: tool.name,
399
+ args: mappedArgs,
400
+ generation: queryCtx.callbackGeneration,
398
401
  resolve: (result) => {
399
402
  queryCtx.markToolResultResolved(toolCallId);
400
403
  resolve(result);
@@ -407,32 +410,6 @@ function buildMcpServers(tools: Tool[], queryCtx: QueryContext): Record<string,
407
410
  return { [MCP_SERVER_NAME]: server };
408
411
  }
409
412
 
410
- // --- Effort level mapping ---
411
- // Pi reasoning levels → CC SDK effort levels
412
-
413
- const REASONING_TO_EFFORT: Record<string, EffortLevel> = {
414
- minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "max", max: "max",
415
- };
416
-
417
- function normalizeEffortOverrideModelKey(value: string): string {
418
- const key = value.trim().toLowerCase();
419
- return key.startsWith(`${PROVIDER_ID}/`) ? key.slice(PROVIDER_ID.length + 1) : key;
420
- }
421
-
422
- export function resolveConfiguredEffort(
423
- modelId: string,
424
- reasoningEffort: EffortLevel | undefined,
425
- providerConfig?: Config["provider"],
426
- ): EffortLevel | undefined {
427
- const target = normalizeEffortOverrideModelKey(modelId);
428
- for (const [key, rawEffort] of Object.entries(providerConfig?.modelEffortOverrides ?? {})) {
429
- const normalizedKey = normalizeEffortOverrideModelKey(key);
430
- if (normalizedKey !== "*" && normalizedKey !== target) continue;
431
- const effort = normalizeEffortLevel(rawEffort) as EffortLevel | undefined;
432
- if (effort) return effort;
433
- }
434
- return (normalizeEffortLevel(providerConfig?.forceEffort) as EffortLevel | undefined) ?? reasoningEffort;
435
- }
436
413
 
437
414
  // --- Provider: streaming function ---
438
415
  //
@@ -449,140 +426,6 @@ export function resolveConfiguredEffort(
449
426
  // currentPiStream, so any leftover messages hit the `!ctx().currentPiStream` guard
450
427
  // in consumeQuery and are skipped before resetTurnState runs.
451
428
 
452
- /** Background consumer: iterates the SDK generator, pushing events to currentPiStream.
453
- * Runs until the query ends. Per turn, the SDK yields stream_events (deltas), then
454
- * an assistant message (completed blocks). On tool_use, the stream is ended by
455
- * whichever path handles it first (processStreamEvent or processAssistantMessage),
456
- * and the MCP handler blocks the generator until pi delivers the tool result. */
457
- async function consumeQuery(
458
- sdkQuery: ReturnType<typeof query>,
459
- customToolNameToPi: Map<string, string>,
460
- model: Model<any>,
461
- cwd: string,
462
- bridgeConfig: Config,
463
- wasAborted: () => boolean,
464
- ): Promise<{ capturedSessionId?: string }> {
465
- let capturedSessionId: string | undefined;
466
-
467
- for await (const message of sdkQuery) {
468
- if (wasAborted()) break;
469
- const queryCtx = ctx();
470
- activeStreamIdleWatchdogs.get(queryCtx)?.noteChunk();
471
- if (!queryCtx.turnOutput) continue;
472
- if (!queryCtx.currentPiStream && !(message.type === "assistant" && queryCtx.turnSawToolCall)) continue;
473
-
474
- switch (message.type) {
475
- case "stream_event":
476
- processStreamEvent(message, customToolNameToPi, model);
477
- break;
478
- case "assistant":
479
- processAssistantMessage(message, model, customToolNameToPi);
480
- break;
481
- case "result":
482
- if (!ctx().turnSawStreamEvent && message.subtype === "success") {
483
- const text = message.result || "";
484
- // The no-stream-events assistant fallback may have already rendered
485
- // this exact text (it does not set turnSawStreamEvent) — re-pushing
486
- // it here is the other half of the duplicated-output bug.
487
- if (ctx().turnBlocks.some((b: any) => b.type === "text" && b.text === text)) {
488
- debug("consumeQuery: result text already rendered by assistant fallback; skipping duplicate");
489
- break;
490
- }
491
- ensureTurnStarted();
492
- ctx().turnBlocks.push({ type: "text", text });
493
- const idx = ctx().turnBlocks.length - 1;
494
- ctx().currentPiStream?.push({ type: "text_start", contentIndex: idx, partial: ctx().turnOutput });
495
- ctx().currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: text, partial: ctx().turnOutput });
496
- ctx().currentPiStream?.push({ type: "text_end", contentIndex: idx, content: text, partial: ctx().turnOutput });
497
- } else if (message.subtype !== "success" && (isExtraUsageRequiredMessage(message) || isUsageLimitMessage(message))) {
498
- // isUsageLimitMessage matches the CLI's own usage-limit copy (SDK
499
- // USAGE_LIMIT_ERROR_PREFIXES) — e.g. a plain "You've hit your weekly
500
- // limit" that the extra-usage regex never matched, so those turns
501
- // used to end as a silent empty success. The /extra-usage helper and
502
- // its hints stay gated on the narrow extra-usage test.
503
- const errorLines = Array.isArray((message as any).errors) ? uniqueNonEmptyLines((message as any).errors) : [];
504
- const errors = errorLines.length > 0 ? errorLines.join("\n") : String(message.subtype ?? "Claude Code rate limit");
505
- const extraUsage = isExtraUsageRequiredMessage(message);
506
- const openedExtraUsage = extraUsage && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "result error");
507
- ctx().handledTerminalError = true;
508
- ctx().turnOutput.stopReason = "error";
509
- const extraUsageHint = openedExtraUsage
510
- ? "\n\nOpened Claude Code /extra-usage helper. Complete billing/admin flow in the browser, then retry the prompt."
511
- : extraUsage
512
- ? "\n\nRun /claude-bridge:extra, or enable Allow extra usage helper in settings."
513
- : "";
514
- ctx().turnOutput.errorMessage = `${errors}${extraUsageHint}`;
515
- ctx().currentPiStream?.push({ type: "error", reason: "error", error: ctx().turnOutput });
516
- ctx().currentPiStream?.end();
517
- ctx().currentPiStream = null;
518
- }
519
- break;
520
- case "system":
521
- if ((message as any).subtype === "init" && (message as any).session_id) {
522
- capturedSessionId = (message as any).session_id;
523
- // Also on this message's query context, so the connector-call audit
524
- // trail can name the child session that executed a call — including
525
- // from the teardown flush, which runs outside this function's scope.
526
- queryCtx.childSessionId = capturedSessionId;
527
- noteFastModeDisabledReason(message, bridgeConfig);
528
- } else if ((message as any).subtype === "model_refusal_fallback") {
529
- const originalModel = (message as any).original_model;
530
- const fallbackModel = (message as any).fallback_model;
531
- updateTurnOutputModel(fallbackModel);
532
- debug("consumeQuery: model_refusal_fallback", JSON.stringify({ originalModel, fallbackModel }));
533
- // Notify only for reroutes we configured, so an unexpected pairing from
534
- // Claude Code is still logged above but not announced as one of ours.
535
- if (typeof fallbackModel === "string" && typeof originalModel === "string" && fallbackModelForPrimaryModel(originalModel) === fallbackModel) {
536
- safeNotify(
537
- `Claude bridge switched ${modelDisplayName(originalModel)} to ${modelDisplayName(fallbackModel)} after Claude Code safety fallback.`,
538
- "info",
539
- );
540
- }
541
- }
542
- break;
543
- case "user":
544
- // Mostly the SDK echoing the prompt back — nothing to render. The one
545
- // thing worth reading is a child-executed tool's real result, which
546
- // arrives here and nowhere else.
547
- noteChildExecutedToolResults(message);
548
- break;
549
- case "rate_limit_event": {
550
- const info = (message as any).rate_limit_info;
551
- debug("consumeQuery: rate_limit_event", JSON.stringify(info).slice(0, 300));
552
- if (info?.status === "rejected") {
553
- const resetsAt = formatResetTimestamp(info.resetsAt);
554
- const resetAtMs = resetTimestampMs(info.resetsAt);
555
- const reason = `${info.rateLimitType ?? "unknown"} rate limit`;
556
- const launchedExtraUsage = isExtraUsageRequiredMessage(info) && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, reason);
557
- emitRateLimitEvent({
558
- model: model.id,
559
- provider: PROVIDER_ID,
560
- rateLimitType: info.rateLimitType,
561
- reason,
562
- resetAt: info.resetsAt,
563
- ...(Number.isFinite(resetAtMs) ? { resetAtMs } : {}),
564
- source: "claude-bridge",
565
- status: "rejected",
566
- });
567
- piUI?.notify(`${RATE_LIMIT_TOKEN} Claude ${reason} hit — resets ${resetsAt}${launchedExtraUsage ? "; opened /extra-usage helper" : ""}`, "warning");
568
- } else if (info?.status === "allowed_warning") {
569
- const warning = formatAllowedRateLimitWarning(info);
570
- if (warning) piUI?.notify(warning, "warning");
571
- else debug("consumeQuery: suppressed low/ambiguous allowed_warning rate_limit_event", JSON.stringify(info).slice(0, 300));
572
- }
573
- break;
574
- }
575
- default:
576
- debug("consumeQuery: unhandled SDK message type", message.type);
577
- break;
578
- }
579
- }
580
-
581
- // DEBUG: trace when consumeQuery exits
582
- debug(`consumeQuery: for-await loop exited, wasAborted=${wasAborted()}, capturedSessionId=${capturedSessionId?.slice(0, 8) ?? "none"}`);
583
-
584
- return { capturedSessionId };
585
- }
586
429
 
587
430
  // Claim the primary-instance token for this module instance if unclaimed, and
588
431
  // report whether this instance is the primary. First-loaded instance wins,
@@ -602,6 +445,9 @@ function claimPrimaryInstance(): boolean {
602
445
  // replace-by-id), and logout-hiding is the provider's own auth check.
603
446
  function releaseProviderTokens(event: string): void {
604
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
+ }
605
451
  if (g[ACTIVE_STREAM_SIMPLE_KEY] === streamClaudeAgentSdk) {
606
452
  debug(`${event}: clearing ACTIVE_STREAM_SIMPLE_KEY`);
607
453
  g[ACTIVE_STREAM_SIMPLE_KEY] = undefined;
@@ -652,19 +498,27 @@ function applyProviderRegistration(trigger: string): void {
652
498
  }
653
499
  return;
654
500
  }
655
- const credentialed = hasClaudeCredentials();
501
+ const credentialed = hasClaudeCredentials() || Boolean(resolveClaudeAccountRouter());
656
502
  debug(`${trigger}: native registration upsert, credentialed=${credentialed} (module=${moduleInstanceId})`);
657
503
  // Start the connector inventory now, not on the first turn: the query path
658
504
  // can only read a synchronous snapshot, so priming here is what gets the
659
- // declarations in place before turn 1 (vstack#832). Fire and forget —
660
- // registration must not wait on the network. Only worth it when a Claude
661
- // account is actually connected.
662
- if (credentialed && connectorsEnabledFor(loadConfig(process.cwd()))) primeConnectorServers();
505
+ // declarations in place before turn 1 (kendex#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();
663
509
  // Claim ordering: stream guard BEFORE registerProvider so a concurrent
664
510
  // subagent can never observe a registered provider without an owner.
665
511
  g[ACTIVE_STREAM_SIMPLE_KEY] = streamClaudeAgentSdk;
666
512
  try {
667
- nativeProviderInstance ??= buildNativeProvider(_piAi, MODELS, streamClaudeAgentSdk as (...args: unknown[]) => unknown);
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
+ );
668
522
  (pi.registerProvider as (provider: unknown) => void)(nativeProviderInstance);
669
523
  } catch (err) {
670
524
  // Self-heal: release ONLY the stream guard we just claimed so a later
@@ -676,9 +530,27 @@ function applyProviderRegistration(trigger: string): void {
676
530
  }
677
531
 
678
532
  /** Provider entry point. Pi calls this for each new prompt and each tool result.
679
- * Two cases: tool result delivery (active query) or fresh query. */
680
- 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 {
681
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
+ };
682
554
 
683
555
  // DEBUG: trace followUp message triggering
684
556
  const lastMsgRole = context.messages[context.messages.length - 1]?.role;
@@ -693,13 +565,20 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
693
565
  const queryCtx = ctx();
694
566
  queryCtx.currentPiStream = stream;
695
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;
696
572
  activeStreamIdleWatchdogs.get(queryCtx)?.refresh();
697
573
  const allResults = extractAllToolResults(context);
698
574
  debug(`provider: tool results, ${allResults.length} results, ${queryCtx.pendingToolCalls.size} waiting handlers, ctx.msgs=${context.messages.length}`);
699
575
  const unmatchedResultIds: string[] = [];
700
576
  for (const result of allResults) {
701
577
  const id = result.toolCallId;
702
- 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).
703
582
  queryCtx.markToolResultUnmatched(id);
704
583
  unmatchedResultIds.push(id);
705
584
  debug(`ERROR: tool result [${id}] has no registered tool_call id; refusing to queue or deliver`);
@@ -718,7 +597,9 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
718
597
  debug(`WARNING: tool result without toolCallId, cannot match`);
719
598
  }
720
599
  if (queryCtx.pendingToolCalls.size > 0 && queryCtx.pendingResults.size > 0) {
721
- 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}`);
722
603
  }
723
604
  }
724
605
  if (unmatchedResultIds.length > 0) {
@@ -726,13 +607,33 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
726
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 ? ", ..." : ""}` }],
727
608
  isError: true,
728
609
  };
729
- 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
+ }
730
616
  queryCtx.pendingToolCalls.clear();
731
617
  reportToolResultMismatch(queryCtx, "unmatched tool result", cwd);
732
618
  }
733
619
  if (queryCtx.pendingToolCalls.size > 0) {
734
- debug(`WARNING: ${queryCtx.pendingToolCalls.size} MCP handlers still waiting after delivering ${allResults.length} results`);
735
- 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, kendex#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
+ }
736
637
  }
737
638
 
738
639
  // Detect user messages (steer/followUp) that pi injected into context
@@ -743,16 +644,53 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
743
644
  // - A followUp is delivered between tool-result turns.
744
645
  // The bridge can't forward these mid-query (the SDK query is in progress),
745
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 (kendex#967 — only the LAST of several trailing user
650
+ // messages was captured while the cursor skipped them all).
651
+ let capturedThrough = context.messages.length;
746
652
  if (lastMsgRole === "user") {
747
- const userPrompt = extractUserPrompt(context.messages);
748
- if (userPrompt) {
749
- ctx().deferredUserMessages.push(userPrompt);
750
- 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 (kendex#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 (kendex#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 (kendex#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
+ });
751
675
  }
752
676
  }
753
677
 
754
- if (sharedSession) sharedSession.cursor = context.messages.length;
755
- 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 (kendex#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);
756
694
  return stream;
757
695
  }
758
696
 
@@ -762,12 +700,17 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
762
700
  const lastMsg = context.messages[context.messages.length - 1];
763
701
  if (lastMsg?.role === "toolResult") {
764
702
  debug(`provider: orphaned tool result after abort, emitting end_turn`);
765
- 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 (kendex#1001).
706
+ const activeSession = getSharedSession();
707
+ if (activeSession && stackDepth() === 0 && !ctx().detachedFromSharedSession) setSharedSession({ ...activeSession, cursor: context.messages.length });
766
708
  const c = ctx(); // capture current context for the microtask
767
709
  queueMicrotask(() => {
768
710
  c.resetTurnState(model);
769
711
  stream.push({ type: "done", reason: "stop", message: c.turnOutput });
770
712
  stream.end();
713
+ releaseEphemeralLane();
771
714
  });
772
715
  return stream;
773
716
  }
@@ -778,12 +721,12 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
778
721
  // tool-result delivery of an in-flight query, handled above, where creds were
779
722
  // valid at start and failing mid-turn would break tool pairing). This bounds
780
723
  // the logout-visibility window from "next session boundary" to "first use":
781
- // if credentials vanished since the last session_start, (a) re-upsert the
782
- // provider (primary-only) so pi's availability recompute hides the models,
783
- // and (b) fail this request with a clear, actionable message instead of
784
- // letting the SDK spawn die with a generic error. The check is cheap
785
- // (existsSync + env reads only, no credential contents).
786
- if (!hasClaudeCredentials()) {
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
727
+ // message instead of letting the SDK spawn die with a generic error. The
728
+ // check is cheap (existsSync + env reads only, no credential contents).
729
+ if (!hasClaudeCredentials() && !resolveClaudeAccountRouter()) {
787
730
  try { applyProviderRegistration("pre-spawn"); } catch { /* best effort */ }
788
731
  const message = "Claude account not connected — connect an account (or run `claude login`) and retry.";
789
732
  debug(`provider: pre-spawn credential check failed; failing fast: ${message}`);
@@ -798,6 +741,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
798
741
  queueMicrotask(() => {
799
742
  stream.push({ type: "error", reason: "error", error: errorOutput });
800
743
  stream.end();
744
+ releaseEphemeralLane();
801
745
  });
802
746
  return stream;
803
747
  }
@@ -812,25 +756,167 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
812
756
  ctx().currentPiStream = stream;
813
757
  ctx().pendingToolCalls.clear();
814
758
  ctx().pendingResults.clear();
759
+ ctx().reapedResults.clear();
760
+ ctx().forwardedToolCallIds.clear();
761
+ ctx().deadToolCallIds.clear();
762
+ ctx().callbackGeneration = 0;
815
763
  ctx().deferredUserMessages = [];
816
764
  ctx().resetTurnState(model);
817
765
  ctx().resetToolTracking();
818
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;
819
860
 
820
861
  const { mcpTools, customToolNameToSdk, customToolNameToPi } = resolveMcpTools(context);
821
- const promptBlocks = extractUserPromptBlocks(context.messages);
822
- let promptText = extractUserPrompt(context.messages) ?? "";
823
862
 
824
- // Guard: empty prompt means the last context message isn't a user message.
825
- // This should never happen with the state stack fix — dump diagnostics if it does.
826
- 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, kendex#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) {
827
907
  diagDump("empty_prompt", {
828
908
  contextLength: context.messages.length,
829
909
  lastMsgRole: lastMsg?.role,
830
910
  isReentrant,
831
911
  stackDepth: stackDepth(),
832
912
  activeQueryExists: ctx().activeQuery !== null,
833
- 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
+ })(),
834
920
  messageRoles: context.messages.map((m, i) => `[${i}]${m.role}`).join(" "),
835
921
  });
836
922
  // Recover: use a continuation prompt so the SDK doesn't send an empty text block
@@ -841,119 +927,78 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
841
927
  ? wrapPromptStream(promptBlocks)
842
928
  : promptText;
843
929
  const mcpServers = buildMcpServers(mcpTools, ctx());
844
- const bridgeConfig = loadConfig(cwd);
845
- const providerSettings = bridgeConfig.provider ?? {};
846
- // Whether to expose the Claude account's claude.ai cloud MCP connectors
847
- // (Gmail/Calendar/Drive). Enabled via env or config; drives setting-sources,
848
- // tool isolation, and the ENABLE_CLAUDEAI_MCP_SERVERS child-env gate below.
849
- const enableCloudMcp = connectorsEnabledFor(bridgeConfig);
850
- // Connector WRITE control: read-only by default (writes denied); the one-shot
851
- // approved-write executor sets CLAUDE_BRIDGE_CONNECTOR_WRITE=allow / config.
852
- const connectorWriteMode = connectorWriteModeFor(bridgeConfig);
853
- // Declare the account's connected connectors explicitly so `alwaysLoad` can
854
- // hold startup until they attach — otherwise the turn-1 manifest is built
855
- // before the CLI has fetched them (vstack#832).
856
- const connectorServers = enableCloudMcp ? connectorServersSnapshot() : {};
857
- const appendSystemPrompt = providerSettings.appendSystemPrompt !== false;
858
- const agentsAppend = appendSystemPrompt ? extractAgentsAppend() : undefined;
859
- const skillsAppend = appendSystemPrompt ? extractSkillsBlock(context.systemPrompt) : undefined;
860
- const promptContextAppend = buildPromptContextAppend(context.systemPrompt, cwd, bridgeConfig.promptContext ?? {});
861
- const appendParts = [agentsAppend, skillsAppend, promptContextAppend.text].filter((part): part is string => Boolean(part));
862
- const systemPromptAppend = appendParts.length > 0 ? appendParts.join("\n\n") : undefined;
863
-
864
- // MCP auto-loading suppression: with appendSystemPrompt=true (default), the
865
- // SDK uses isolation mode and avoids filesystem settings. If users turn that
866
- // off, load user/project settings but pass --strict-mcp-config so Claude Code
867
- // ignores auto-discovered filesystem MCP servers while Pi owns tool execution.
868
- // claude.ai cloud MCP connectors only load when Claude Code resolves its
869
- // filesystem setting sources. The SDK treats settingSources=undefined as
870
- // isolation (no sources), which drops the connectors even with
871
- // ENABLE_CLAUDEAI_MCP_SERVERS=1. When connectors are enabled we force the CLI
872
- // default source set so Gmail/Calendar/Drive surface.
873
- const settingSources: SettingSource[] | undefined = enableCloudMcp
874
- ? (providerSettings.settingSources ?? ["user", "project", "local"])
875
- : appendSystemPrompt
876
- ? undefined
877
- : providerSettings.settingSources ?? ["user", "project"];
878
- const strictMcpConfigEnabled = !appendSystemPrompt && providerSettings.strictMcpConfig !== false;
879
- const claudeExecutable = resolveClaudeExecutable(providerSettings.pathToClaudeCodeExecutable);
880
- const claudeExecutablePreflight = claudeExecutable ? preflightClaudeExecutable(claudeExecutable, cwd) : undefined;
881
- const { sessionId: resumeSessionId } = syncSharedSession(context.messages, cwd, customToolNameToSdk, model.id);
882
-
883
- // Prefer the model's own thinkingLevelMap when present (pi-ai 0.72+ ships
884
- // per-model overrides — e.g. opus-4-7 wants xhigh→xhigh, not xhigh→max).
885
- // Fall back to our generic table for older pi-ai or unmapped levels.
886
- const requestedEffort = options?.reasoning
887
- ? ((model as any).thinkingLevelMap?.[options.reasoning] as EffortLevel | undefined)
888
- ?? REASONING_TO_EFFORT[options.reasoning]
889
- : undefined;
890
- const effort = resolveConfiguredEffort(model.id, requestedEffort, providerSettings);
891
-
892
- const extraArgs: Record<string, string | null> = {};
893
- // Opus 4.7 defaults thinking.display to "omitted" (empty thinking text in stream).
894
- // Force summarized so thinking_delta events arrive. See anthropics/claude-agent-sdk-python#830.
895
- // Deliberately the raw flag, NOT the typed `thinking` option: every non-disabled
896
- // ThinkingConfig also emits `--thinking adaptive` or `--max-thinking-tokens`
897
- // (verified in sdk.mjs flag mapping), so the typed form cannot set display
898
- // without overriding the model's thinking mode alongside our `--effort`.
899
- if (effort) extraArgs["thinking-display"] = "summarized";
900
- const fallbackModel = fallbackModelForPrimaryModel(model.id);
901
-
902
- // Suppress claude.ai cloud MCP servers (Figma/Canva/etc. auto-discovered via OAuth
903
- // when the user is logged into Anthropic). These are a separate code path from
904
- // filesystem MCP and are NOT blocked by --strict-mcp-config or settingSources=undefined.
905
- // The native CC binary gates them on env var ENABLE_CLAUDEAI_MCP_SERVERS: setting it
906
- // to "0"/"false"/"no"/"off" makes the loader return early before any cloud fetch.
907
- // DISABLE_AUTO_COMPACT=1: pi owns context-management and propagates its own
908
- // /compact via session_compact (see handler in default export). Letting CC
909
- // also autocompact would double-flush the prompt cache and races pi's
910
- // threshold with CC's, including CC's anti-thrashing guard (issue #8).
911
- // Manual /compact in CC still works (we never invoke it).
912
- // When connectors are enabled, allow claude.ai cloud MCP servers so the
913
- // authenticated account's Gmail/Calendar/Drive tools load. Default stays "0".
914
- const childEnv = { ...process.env, ENABLE_CLAUDEAI_MCP_SERVERS: enableCloudMcp ? "1" : "0", DISABLE_AUTO_COMPACT: "1" };
915
- 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({
916
933
  cwd,
917
- model: model.id,
918
- env: childEnv,
919
- ...connectorQueryOptions(enableCloudMcp, connectorWriteMode),
920
- permissionMode: "bypassPermissions",
921
- includePartialMessages: true,
922
- ...(fallbackModel ? { fallbackModel } : {}),
923
- ...(providerSettings.fastMode ? { settings: { fastMode: true } } : {}),
924
- systemPrompt: {
925
- type: "preset", preset: "claude_code",
926
- append: systemPromptAppend ? systemPromptAppend : undefined,
927
- },
928
- extraArgs,
929
- ...(strictMcpConfigEnabled ? { strictMcpConfig: true } : {}),
930
- ...(effort ? { effort } : {}),
931
- ...(settingSources ? { settingSources } : {}),
932
- ...(mcpServers || Object.keys(connectorServers).length > 0
933
- ? { mcpServers: { ...(mcpServers ?? {}), ...connectorServers } as NonNullable<Parameters<typeof query>[0]["options"]>["mcpServers"] }
934
- : {}),
935
- ...(resumeSessionId ? { resume: resumeSessionId } : {}),
936
- ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
937
- spawnClaudeCodeProcess: spawnClaudeCodeWithDiagnostics,
938
- ...makeCliDebugOptions("provider"),
939
- };
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;
940
945
 
941
946
  debug("provider: fresh query",
942
- `model=${model.id} msgs=${context.messages.length} tools=${mcpTools.length}`,
943
- `resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
944
- `fallback=${fallbackModel ?? "none"}`,
945
- `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}`,
946
951
  `claudeExec=${claudeExecutablePreflight ? `${claudeExecutablePreflight.fileType}:${claudeExecutablePreflight.path}` : "sdk-default"}`,
947
952
  `prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`);
948
953
 
949
954
  // 3. Start SDK query and claim it for this context
950
955
  let wasAborted = false;
951
956
  let streamIdleTimedOut = false;
952
- const sdkQuery = query({ prompt, options: queryOptions });
957
+ let retryRequested = false;
958
+ let retryFailure: ClaudeAttemptFailure | undefined;
959
+ const sdkQuery = sdkQueryFactory({ prompt, options: queryOptions });
953
960
  ctx().activeQuery = sdkQuery;
954
961
 
955
962
  // 4. Capture context for abort handling (must be AFTER pushContext)
956
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 (kendex#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
+ };
957
1002
 
958
1003
  const requestAbort = () => {
959
1004
  // interrupt() asks the CLI to stop gracefully; close() kills it immediately.
@@ -961,6 +1006,39 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
961
1006
  void sdkQuery.interrupt().catch(() => {});
962
1007
  try { sdkQuery.close(); } catch {}
963
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
+
964
1042
  const streamIdleTimeoutMs = streamIdleTimeoutMsFromEnv();
965
1043
  const streamIdleWatchdog = streamIdleTimeoutMs > 0
966
1044
  ? createStreamIdleWatchdog({
@@ -974,15 +1052,24 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
974
1052
  onTimeout: ({ idleMs, timeoutMs }) => {
975
1053
  if (streamIdleTimedOut || wasAborted || options?.signal?.aborted || abortCtx.activeQuery !== sdkQuery) return;
976
1054
  streamIdleTimedOut = true;
977
- abortCtx.deferredUserMessages = [];
978
- abortCtx.handledTerminalError = true;
979
- if (sharedSession) setSharedSession({ ...sharedSession, needsRebuild: true, forceRotate: true });
1055
+ dropDeferredUserMessages("stream-idle-timeout");
1056
+ markRebuildForThisQuery({ forceRotate: true });
980
1057
  const errorMessage = buildStreamIdleTimeoutErrorMessage(timeoutMs);
981
- 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;
982
1069
  emitRateLimitEvent({
983
1070
  idleMs,
984
- model: model.id,
985
- provider: PROVIDER_ID,
1071
+ model: queryModel.id,
1072
+ provider: queryModel.provider,
986
1073
  rateLimitType: "stream_idle",
987
1074
  reason: "Claude Code stream idle timeout",
988
1075
  retryAfterMs: STREAM_IDLE_BACKOFF_HINT_MS,
@@ -990,7 +1077,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
990
1077
  status: "rejected",
991
1078
  timeoutMs,
992
1079
  });
993
- 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");
994
1081
  if (abortCtx.turnOutput) {
995
1082
  abortCtx.turnOutput.stopReason = "error";
996
1083
  abortCtx.turnOutput.errorMessage = errorMessage;
@@ -1012,89 +1099,180 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1012
1099
  activeStreamIdleWatchdogs.set(abortCtx, streamIdleWatchdog);
1013
1100
  streamIdleWatchdog.refresh();
1014
1101
  }
1015
- 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, () => {
1016
1106
  wasAborted = true;
1017
1107
  // Prevent stale deferred messages from being replayed by parent on pop
1018
- abortCtx.deferredUserMessages = [];
1019
- reportToolResultMismatch(abortCtx, "abort", cwd, { forceRotate: true });
1108
+ dropDeferredUserMessages("abort");
1109
+ reportToolResultMismatch(abortCtx, "abort", cwd, {
1110
+ expectedInterruption: true,
1111
+ forceRotate: true,
1112
+ });
1020
1113
  const drained = drainPendingToolCalls(abortCtx, "abort");
1021
1114
  if (drained > 0) debug(`provider: abort drained ${drained} waiting MCP handler(s) as errors`);
1022
1115
  abortCtx.pendingResults.clear();
1023
1116
  requestAbort();
1024
- };
1117
+ });
1025
1118
  if (options?.signal) {
1026
1119
  if (options.signal.aborted) onAbort();
1027
1120
  else options.signal.addEventListener("abort", onAbort, { once: true });
1028
1121
  }
1029
1122
 
1030
- // Background consumer runs until query ends.
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.
1031
1152
  // The handlers below use the CAPTURED abortCtx, never the live ctx(): the two
1032
1153
  // only differ while a reentrant (subagent) context is pushed, and a parent
1033
1154
  // query CAN end in that window (abort, child process death throwing out of
1034
1155
  // the generator). Live-ctx handlers there mutated the subagent's turn state
1035
1156
  // and stream and skipped the parent's own teardown entirely.
1036
- consumeQuery(sdkQuery, customToolNameToPi, model, cwd, bridgeConfig, () => wasAborted)
1037
- .then(async ({ capturedSessionId }) => {
1038
- debug(`provider: consumeQuery completed, stopReason=${abortCtx.turnOutput?.stopReason}, error=${abortCtx.turnOutput?.errorMessage}, aborted=${wasAborted}`);
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}`);
1039
1160
  if (streamIdleTimedOut) {
1040
- abortCtx.deferredUserMessages = [];
1041
- 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`);
1042
1163
  return;
1043
1164
  }
1044
1165
 
1045
1166
  // --- Abort detection in normal completion path ---
1046
1167
  if (wasAborted || options?.signal?.aborted) {
1047
- if (sharedSession) setSharedSession({ ...sharedSession, needsRebuild: true, forceRotate: true });
1048
- abortCtx.deferredUserMessages = [];
1168
+ markRebuildForThisQuery({ forceRotate: true });
1169
+ dropDeferredUserMessages("abort-completion");
1049
1170
  debug(`provider: abort detected, marked sharedSession needsRebuild + forceRotate`);
1050
- if (abortCtx.turnOutput) {
1051
- abortCtx.turnOutput.stopReason = "aborted";
1052
- abortCtx.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 } : {}) });
1053
1197
  }
1054
- abortCtx.currentPiStream?.push({ type: "error", reason: "aborted", error: abortCtx.turnOutput! });
1055
- abortCtx.currentPiStream?.end();
1056
- abortCtx.currentPiStream = null;
1057
1198
  return;
1058
1199
  }
1059
1200
 
1060
1201
  // --- Capture session ID ---
1061
- const sessionId = capturedSessionId ?? sharedSession?.sessionId;
1202
+ const activeSession = getSharedSession();
1203
+ const sessionId = capturedSessionId ?? activeSession?.sessionId;
1062
1204
  if (sessionId) {
1063
- const cursor = Math.max(context.messages.length, abortCtx.latestCursor, sharedSession?.cursor ?? 0);
1064
- debug(`provider: query done, session=${sessionId.slice(0, 8)}, cursor=${cursor}`);
1065
- 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 });
1066
1210
  }
1211
+ // The failure branch above returned, so reaching here means success.
1212
+ if (account && router) safeRouterCall("recordSuccess", () => router.recordSuccess(account.profileId, options?.sessionId));
1067
1213
 
1068
1214
  // --- Replay deferred user messages as continuation queries ---
1069
1215
  // Only for outermost queries — reentrant (subagent) queries leave
1070
1216
  // deferred messages for the parent to handle after it finishes.
1071
1217
  try {
1072
1218
  while (abortCtx.deferredUserMessages.length > 0 && !isReentrant && !wasAborted) {
1073
- const steerPrompt = abortCtx.deferredUserMessages.shift()!;
1074
- debug(`provider: replaying deferred user message: ${steerPrompt.slice(0, 60)}`);
1075
- abortCtx.resetTurnState(model);
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);
1076
1223
  abortCtx.resetToolTracking();
1077
1224
 
1078
- const resumeId = sharedSession?.sessionId;
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;
1079
1228
  if (!resumeId) {
1080
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);
1081
1233
  break;
1082
1234
  }
1083
1235
 
1084
1236
  const contOptions = { ...queryOptions, resume: resumeId, ...makeCliDebugOptions("continuation") };
1085
- const contQuery = query({ prompt: steerPrompt, options: contOptions });
1237
+ // Runs carrying image blocks replay as blocks (wrapPromptStream) so
1238
+ // the images survive; text-only runs stay plain strings (kendex#993).
1239
+ const contQuery = sdkQueryFactory({ prompt: steer.blocks ? wrapPromptStream(steer.blocks) : steer.text, options: contOptions });
1086
1240
  abortCtx.activeQuery = contQuery;
1087
1241
 
1088
- 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}`);
1089
1243
 
1090
1244
  try {
1091
- const { capturedSessionId: contSid } = await consumeQuery(contQuery, customToolNameToPi, model, cwd, bridgeConfig, () => wasAborted);
1092
- 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;
1093
1261
  if (sid) {
1094
- setSharedSession({ sessionId: sid, cursor: sharedSession?.cursor ?? 0, cwd });
1262
+ persistSession({ sessionId: sid, cursor: activeSession?.cursor ?? 0, cwd, ...accountScope });
1095
1263
  }
1096
1264
  } catch (contError) {
1097
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
+ }
1098
1276
  break;
1099
1277
  } finally {
1100
1278
  contQuery.close();
@@ -1108,26 +1286,35 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1108
1286
  finalizeCurrentStream(abortCtx.turnOutput?.stopReason, abortCtx);
1109
1287
  })
1110
1288
  .catch((error) => {
1111
- debug(`provider: query error, model=${model.id}, aborted=${Boolean(options?.signal?.aborted)}, error=`, error);
1112
- const suppressDuplicateError = abortCtx.handledTerminalError || streamIdleTimedOut;
1113
- const openedExtraUsage = !suppressDuplicateError && isExtraUsageRequiredMessage(error) && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "query error");
1114
- if ((wasAborted || options?.signal?.aborted) && sharedSession) {
1115
- setSharedSession({ ...sharedSession, needsRebuild: true, forceRotate: true });
1116
- } else {
1117
- 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 });
1118
1293
  }
1119
- abortCtx.deferredUserMessages = [];
1120
- if (suppressDuplicateError) {
1121
- debug("provider: suppressing duplicate query error after terminal error was already emitted");
1122
- 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();
1123
1299
  }
1124
- if (abortCtx.turnOutput) {
1125
- abortCtx.turnOutput.stopReason = options?.signal?.aborted ? "aborted" : "error";
1126
- abortCtx.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;
1127
1303
  }
1128
- abortCtx.currentPiStream?.push({ type: "error", reason: (abortCtx.turnOutput?.stopReason ?? "error") as "aborted" | "error", error: abortCtx.turnOutput! });
1129
- abortCtx.currentPiStream?.end();
1130
- abortCtx.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));
1131
1318
  })
1132
1319
  .finally(() => {
1133
1320
  streamIdleWatchdog?.dispose();
@@ -1136,195 +1323,55 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1136
1323
  const cause = toolCallDrainCause({ wasAborted, signalAborted: options?.signal?.aborted, streamIdleTimedOut });
1137
1324
  teardownQuery(abortCtx, sdkQuery, cause, cwd, isReentrant);
1138
1325
  sdkQuery.close();
1139
- });
1140
-
1141
- return stream;
1142
- }
1143
-
1144
- function commandCwd(ctx: unknown): string {
1145
- const value = (ctx as { cwd?: unknown })?.cwd;
1146
- return typeof value === "string" && value.length > 0 ? value : process.cwd();
1147
- }
1148
-
1149
- async function tryOpenExtensionManagerSettings(ctx: { ui: ExtensionUIContext }): Promise<boolean> {
1150
- const host = globalThis as unknown as Record<PropertyKey, unknown>;
1151
- const openQuickSettings = host[Symbol.for("vstack.pi.extension-manager.open-quick-settings")];
1152
- if (typeof openQuickSettings !== "function") return false;
1153
- try {
1154
- await (openQuickSettings as (ctx: unknown, hint?: string) => Promise<void>)(ctx, "@vanillagreen/pi-claude-bridge");
1155
- return true;
1156
- } catch {
1157
- return false;
1158
- }
1159
- }
1160
-
1161
- function showBridgeStatus(ctx: { ui: ExtensionUIContext; cwd?: string }): void {
1162
- const config = loadConfig(commandCwd(ctx));
1163
- ctx.ui.notify([
1164
- `Claude bridge: ${config.enabled === false ? "disabled" : "enabled"}`,
1165
- `Extra usage auto-helper: ${extraUsageAllowed(config) ? "on" : "off"} (settings)`,
1166
- `Use /claude-bridge:extra to run Claude Code /extra-usage now.`,
1167
- ].join("\n"), "info");
1168
- }
1169
-
1170
- // Read a credential file, treating any read error as "absent" — a missing or
1171
- // unreadable candidate must fall through to the next one, not abort resolution.
1172
- function readCredentialFile(path: string): string | undefined {
1173
- try {
1174
- return nodeReadFileSync(path, "utf8");
1175
- } catch {
1176
- return undefined;
1177
- }
1178
- }
1179
-
1180
- // Connector declarations for the query path (vstack#832), cached per credential
1181
- // scope. The inventory is one HTTPS round trip; doing it per TURN would add that
1182
- // latency to every message, and an account's connector set does not change
1183
- // mid-session. Keyed by CLAUDE_CONFIG_DIR because that is what selects the
1184
- // account — the org UUID in the request path is ignored, so two accounts on one
1185
- // host differ only by which credential directory was read.
1186
- //
1187
- // FAILS OPEN. If credentials or the inventory call fail we return no
1188
- // declarations and the turn proceeds exactly as it does today: connectors may
1189
- // race, which is the bug, but a network blip must not break the turn outright.
1190
- const connectorServerCache = new Map<string, Record<string, unknown>>();
1191
- const connectorServerPending = new Set<string>();
1192
-
1193
- function connectorScopeKey(): string {
1194
- return process.env.CLAUDE_CONFIG_DIR?.trim() || "<default>";
1195
- }
1196
-
1197
- // Kick off the inventory fetch for the current credential scope. Fire and
1198
- // forget: the query path can only read a SYNCHRONOUS snapshot, because
1199
- // streamClaudeAgentSdk returns a stream and claims the SDK query handle in the
1200
- // same tick — there is no await boundary to hang a fetch on without
1201
- // restructuring abort handling.
1202
- //
1203
- // Primed at provider registration so the result is in hand well before the
1204
- // first turn (the call measured ~400ms against app startup). If a turn arrives
1205
- // first it declares nothing and behaves exactly as it does today — the race is
1206
- // back for that one turn, which is the bug, but never worse than the status quo.
1207
- //
1208
- // FAILS OPEN throughout: no credentials, a failed inventory, or a thrown call
1209
- // all resolve to "declare nothing" rather than breaking the turn.
1210
- export function primeConnectorServers(): void {
1211
- const key = connectorScopeKey();
1212
- if (connectorServerCache.has(key) || connectorServerPending.has(key)) return;
1213
- connectorServerPending.add(key);
1214
- void (async () => {
1215
- try {
1216
- const credentials = resolveClaudeOAuth(readCredentialFile);
1217
- if (!credentials) {
1218
- debug("connectors: no OAuth credentials; declaring none");
1219
- connectorServerCache.set(key, {});
1220
- return;
1221
- }
1222
- const inventory = await listAccountConnectors({ credentials });
1223
- if (!inventory.ok) {
1224
- debug(`connectors: inventory failed (${inventory.reason}); declaring none`);
1225
- connectorServerCache.set(key, {});
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";
1344
+ }
1345
+ stream.push({ type: "error", reason: "aborted", error: abortCtx.turnOutput! });
1346
+ stream.end();
1226
1347
  return;
1227
1348
  }
1228
- const servers = connectorMcpServers(inventory);
1229
- debug(`connectors: declaring ${Object.keys(servers).length} of ${inventory.connectors.length} installed`,
1230
- Object.keys(servers).join(", ") || "none");
1231
- connectorServerCache.set(key, servers);
1232
- // Persist so the NEXT cold process has this synchronously. Priming always
1233
- // loses the race against turn 1 in its own process; a cache written by an
1234
- // earlier run is the only thing turn 1 can read in time (vstack#870).
1235
- if (writeCachedConnectors(inventory.connectors, key)) {
1236
- debug(`connectors: cached ${inventory.connectors.length} entries`);
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);
1237
1367
  }
1238
- } catch (error) {
1239
- debug("connectors: declaration lookup threw; declaring none", error);
1240
- connectorServerCache.set(key, {});
1241
- } finally {
1242
- connectorServerPending.delete(key);
1243
- }
1244
- })();
1245
- }
1246
-
1247
- /** Synchronous snapshot for the query path; `{}` until priming resolves. */
1248
- function connectorServersSnapshot(): Record<string, unknown> {
1249
- const key = connectorScopeKey();
1250
- const ready = connectorServerCache.get(key);
1251
- if (ready) return ready;
1252
- // Always start (or continue) the live fetch — the cache is a head start, not
1253
- // a replacement, and the refresh keeps the next process current.
1254
- primeConnectorServers();
1255
- // Fall back to the previous run's inventory, read synchronously. This is the
1256
- // only thing that can populate turn 1 of a cold process, because priming
1257
- // cannot finish before the first query is built (vstack#870).
1258
- const cached = readCachedConnectors(key);
1259
- if (!cached) return {};
1260
- const servers = connectorMcpServers({ ok: true, complete: true, connectors: cached });
1261
- if (Object.keys(servers).length === 0) return {};
1262
- debug(`connectors: turn-1 declarations from cache — ${Object.keys(servers).join(", ")}`);
1263
- return servers;
1264
- }
1265
-
1266
- // Deterministic connector enumeration for the host app (vstack#838). Reports the
1267
- // failure reason rather than an empty list, so "no connectors" and "could not
1268
- // check" stay distinguishable.
1269
- async function reportConnectorInventory(ctx: { ui: ExtensionUIContext }): Promise<void> {
1270
- const credentials = resolveClaudeOAuth(readCredentialFile);
1271
- if (!credentials) {
1272
- ctx.ui.notify("Claude bridge: no Claude OAuth credentials found — cannot enumerate connectors.", "error");
1273
- return;
1274
- }
1275
- const inventory = await listAccountConnectors({ credentials });
1276
- if (!inventory.ok) {
1277
- ctx.ui.notify(`Claude bridge: connector enumeration failed — ${inventory.reason}`, "error");
1278
- return;
1279
- }
1280
- if (inventory.connectors.length === 0) {
1281
- ctx.ui.notify("Claude bridge: this account has no connectors installed.", "info");
1282
- return;
1283
- }
1284
- const names = inventory.connectors.map((c) => c.name).join(", ");
1285
- ctx.ui.notify(`Claude bridge: ${inventory.connectors.length} connector(s) installed — ${names}`, "info");
1286
- }
1287
-
1288
- function registerBridgeCommands(pi: ExtensionAPI): void {
1289
- const guard = pi as unknown as Record<PropertyKey, unknown>;
1290
- if (guard[COMMANDS_REGISTERED_KEY]) return;
1291
- guard[COMMANDS_REGISTERED_KEY] = true;
1292
-
1293
- const runExtraUsage = async (ctx: { ui: ExtensionUIContext; cwd?: string }) => {
1294
- const cwd = commandCwd(ctx);
1295
- if (extraUsageHelperInFlight) {
1296
- ctx.ui.notify("Claude extra usage helper already running.", "info");
1297
- await extraUsageHelperInFlight.catch(() => undefined);
1298
- return;
1299
- }
1300
- try {
1301
- ctx.ui.notify("Claude extra usage helper starting…", "info");
1302
- extraUsageHelperInFlight = runExtraUsageHelper(cwd)
1303
- .finally(() => { extraUsageHelperInFlight = null; });
1304
- const message = await extraUsageHelperInFlight;
1305
- ctx.ui.notify(`Claude extra usage helper: ${message}`, "info");
1306
- } catch (error) {
1307
- const message = error instanceof Error ? error.message : String(error);
1308
- ctx.ui.notify(`Claude extra usage helper failed: ${message}`, "error");
1309
- }
1310
- };
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);
1311
1373
 
1312
- pi.registerCommand("claude-bridge", {
1313
- description: "Open Claude bridge settings/status",
1314
- handler: async (args: string, ctx) => {
1315
- if (args.trim()) ctx.ui.notify("Unknown /claude-bridge argument. Use /claude-bridge:extra to run Claude Code /extra-usage.", "warning");
1316
- if (await tryOpenExtensionManagerSettings(ctx)) return;
1317
- showBridgeStatus(ctx);
1318
- },
1319
- });
1320
- pi.registerCommand("claude-bridge:extra", {
1321
- description: "Run Claude Code /extra-usage through claude-bridge",
1322
- handler: async (_args: string, ctx) => runExtraUsage(ctx),
1323
- });
1324
- pi.registerCommand("claude-bridge:connectors", {
1325
- description: "List the Claude account's installed claude.ai connectors",
1326
- handler: async (_args: string, ctx) => reportConnectorInventory(ctx),
1327
- });
1374
+ return stream;
1328
1375
  }
1329
1376
 
1330
1377
  // --- Extension registration ---
@@ -1336,22 +1383,35 @@ export default function (pi: ExtensionAPI) {
1336
1383
 
1337
1384
  const config = loadConfig(process.cwd());
1338
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();
1339
1390
  registerBridgeCommands(pi);
1340
1391
  if (config.enabled === false) {
1341
1392
  debug("provider: disabled by configuration");
1342
1393
  return;
1343
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
+ }
1344
1402
 
1345
1403
  // Reset shared (Claude) conversation state on pi session lifecycle events.
1346
1404
  // Registration tokens are managed separately by applyProviderRegistration
1347
1405
  // (load / session_start / pre-spawn) and releaseProviderTokens (shutdown), so
1348
1406
  // a mid-session credential flip is handled while token ownership is intact.
1349
1407
  const clearSession = (event: string) => {
1350
- 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"}`);
1351
1410
  setSharedSession(null);
1352
1411
  };
1353
1412
 
1354
- pi.on("session_start", (event, ctx) => {
1413
+ pi.on("session_start", (event, ctx) => runInRequestLane(ctx.sessionManager.getSessionId(), () => {
1414
+ recordStartedLane(ctx.sessionManager, ctx.sessionManager.getSessionId());
1355
1415
  recordProjectTrust(ctx);
1356
1416
  setPiUI(ctx.ui);
1357
1417
  if (event.reason === "new" || event.reason === "resume" || event.reason === "fork") {
@@ -1365,15 +1425,21 @@ export default function (pi: ExtensionAPI) {
1365
1425
  // Live availability flip: re-evaluate credential presence every
1366
1426
  // session_start so login/logout since load is reflected without /reload.
1367
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);
1368
1438
  });
1369
- pi.on("session_shutdown", () => {
1370
- clearSession("session_shutdown");
1371
- releaseProviderTokens("session_shutdown");
1372
- });
1373
- pi.on("message_end", (event, ctx) => {
1439
+ pi.on("message_end", (event, ctx) => runInRequestLane(ctx.sessionManager.getSessionId(), () => {
1374
1440
  const message = (event as { message?: AssistantMessage }).message;
1375
1441
  if (message?.role === "assistant" && message.provider === PROVIDER_ID) schedulePersistSharedSession(ctx);
1376
- });
1442
+ }));
1377
1443
 
1378
1444
  // pi /compact and session-tree navigation (rewind / fork-at-point /
1379
1445
  // branch switch) both mutate pi's messages array out from under the
@@ -1383,16 +1449,17 @@ export default function (pi: ExtensionAPI) {
1383
1449
  // triggers CC's autocompact-thrashing guard (issue #8). Force the next
1384
1450
  // call down the REBUILD path so CC sees the current history.
1385
1451
  const markRebuild = (event: string) => {
1452
+ const activeSession = getSharedSession();
1386
1453
  if (ctx().activeQuery) {
1387
- reportToolResultMismatch(ctx(), event, sharedSession?.cwd ?? process.cwd());
1454
+ reportToolResultMismatch(ctx(), event, activeSession?.cwd ?? process.cwd());
1388
1455
  }
1389
- if (sharedSession) {
1390
- debug(`${event}: marking needsRebuild on session ${sharedSession.sessionId.slice(0, 8)}`);
1391
- setSharedSession({ ...sharedSession, needsRebuild: true });
1456
+ if (activeSession) {
1457
+ debug(`${event}: marking needsRebuild on session ${activeSession.sessionId.slice(0, 8)}`);
1458
+ markSessionForRebuild();
1392
1459
  }
1393
1460
  };
1394
- pi.on("session_compact", () => markRebuild("session_compact"));
1395
- 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")));
1396
1463
 
1397
1464
  // --- Provider ---
1398
1465
  //