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