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