@vanillagreen/pi-claude-bridge 1.9.0 → 2.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 +24 -11
- package/bundle/connector-inventory.js +10 -0
- package/bundle/index.js +2418 -1794
- package/package.json +6 -6
- package/src/assistant-stream.ts +313 -46
- package/src/auth-presence.ts +6 -50
- package/src/bridge-state.ts +43 -1
- package/src/connector-audit.ts +203 -0
- package/src/connector-cache.ts +118 -0
- package/src/connector-inventory.ts +52 -0
- package/src/connectors.ts +142 -1
- package/src/convert.ts +14 -0
- package/src/index.ts +330 -172
- package/src/native-provider.ts +89 -0
- package/src/query-state.ts +218 -9
- package/src/query-teardown.ts +45 -0
- package/src/rate-limit.ts +42 -10
- package/src/session-persistence.ts +7 -2
- package/src/tool-pairing-audit.ts +48 -0
- package/src/typebox-to-zod.ts +9 -3
package/src/index.ts
CHANGED
|
@@ -7,9 +7,11 @@ import { PROVIDER_ID, messageContentToText } from "./convert.js";
|
|
|
7
7
|
import { buildModels, fallbackModelForPrimaryModel, modelDisplayName } from "./models.js";
|
|
8
8
|
import { MCP_SERVER_NAME, MCP_TOOL_PREFIX, extractSkillsBlock } from "./skills.js";
|
|
9
9
|
import { extractAllToolResults as _extractAllToolResults, type McpResult } from "./extract-tool-results.js";
|
|
10
|
-
import { QueryContext, ctx, drainPendingToolCalls, stackDepth, pushContext,
|
|
10
|
+
import { QueryContext, ctx, drainPendingToolCalls, stackDepth, pushContext, toolCallDrainCause } from "./query-state.js";
|
|
11
|
+
import { teardownQuery } from "./query-teardown.js";
|
|
11
12
|
import { loadConfig, normalizeEffortLevel, recordProjectTrust, type Config } from "./config.js";
|
|
12
|
-
import {
|
|
13
|
+
import { hasClaudeCredentials } from "./auth-presence.js";
|
|
14
|
+
import { NATIVE_PROVIDER_UNSUPPORTED_MESSAGE, buildNativeProvider, supportsNativeProvider } from "./native-provider.js";
|
|
13
15
|
import { extractAgentsAppend } from "./agents-md.js";
|
|
14
16
|
import { buildPromptContextAppend } from "./prompt-context.js";
|
|
15
17
|
import { jsonSchemaToZodShape } from "./typebox-to-zod.js";
|
|
@@ -25,6 +27,8 @@ import { listAccountConnectors, resolveClaudeOAuth } from "./connector-inventory
|
|
|
25
27
|
// path their existing manifest already allows, and incidentally keeps esbuild
|
|
26
28
|
// from tree-shaking helpers index.ts never calls itself.
|
|
27
29
|
export {
|
|
30
|
+
connectorProxyUrl,
|
|
31
|
+
connectorServerName,
|
|
28
32
|
connectorServerNamespace,
|
|
29
33
|
connectorsListUrl,
|
|
30
34
|
credentialCandidatePaths,
|
|
@@ -34,27 +38,31 @@ export {
|
|
|
34
38
|
type ConnectorEntry,
|
|
35
39
|
type ConnectorInventory,
|
|
36
40
|
} from "./connector-inventory.js";
|
|
41
|
+
export { connectorCachePath, connectorCacheScopeKey, readCachedConnectors, writeCachedConnectors } from "./connector-cache.js";
|
|
37
42
|
import { debug, diagDump, makeCliDebugOptions, moduleInstanceId } from "./debug.js";
|
|
38
43
|
import { preflightClaudeExecutable, resolveClaudeExecutable, spawnClaudeCodeWithDiagnostics } from "./claude-executable.js";
|
|
39
|
-
import { argKeys, extensionApi, piUI, reportToolResultMismatch, safeNotify, safeToolCallSummary, setExtensionApi, setPiUI, setSharedSession, sharedSession } from "./bridge-state.js";
|
|
40
|
-
import { connectorQueryOptions, connectorWriteModeFor, connectorsEnabledFor } from "./connectors.js";
|
|
44
|
+
import { appendIntegrityEntry, argKeys, extensionApi, piUI, reportToolResultMismatch, safeNotify, safeToolCallSummary, setExtensionApi, setPiUI, setSharedSession, sharedSession } from "./bridge-state.js";
|
|
45
|
+
import { connectorMcpServers, connectorQueryOptions, connectorWriteModeFor, connectorsEnabledFor, isChildExecutedTool } from "./connectors.js";
|
|
46
|
+
import { readCachedConnectors, writeCachedConnectors } from "./connector-cache.js";
|
|
41
47
|
import { restoreSharedSessionFromPi, schedulePersistSharedSession, syncSharedSession } from "./session-persistence.js";
|
|
42
48
|
import { STREAM_IDLE_BACKOFF_HINT_MS, activeStreamIdleWatchdogs, buildStreamIdleTimeoutErrorMessage, createStreamIdleWatchdog, formatDurationShort, streamIdleTimeoutMsFromEnv } from "./stream-idle-watchdog.js";
|
|
43
|
-
import { RATE_LIMIT_AUTO_RESUME_EVENT, RATE_LIMIT_TOKEN, formatAllowedRateLimitWarning, formatResetTimestamp, isExtraUsageRequiredMessage, uniqueNonEmptyLines } from "./rate-limit.js";
|
|
49
|
+
import { RATE_LIMIT_AUTO_RESUME_EVENT, RATE_LIMIT_TOKEN, formatAllowedRateLimitWarning, formatResetTimestamp, isExtraUsageRequiredMessage, isUsageLimitMessage, resetTimestampMs, uniqueNonEmptyLines } from "./rate-limit.js";
|
|
44
50
|
import { mapToolArgs } from "./tool-mapping.js";
|
|
45
|
-
import { ensureTurnStarted, finalizeCurrentStream,
|
|
51
|
+
import { ensureTurnStarted, finalizeCurrentStream, finalizeToolUseTurnFromMcpInvocation, noteChildExecutedToolResults, processAssistantMessage, processStreamEvent, scheduleToolUseTurnEnd, updateTurnOutputModel } from "./assistant-stream.js";
|
|
46
52
|
|
|
47
53
|
// Re-exports: the module decomposition must not change the bundle entry's
|
|
48
54
|
// public surface — unit tests and downstream consumers import these from
|
|
49
55
|
// bundle/index.js.
|
|
50
56
|
export { classifyClaudeExecutableBytes, preflightClaudeExecutable, resolveClaudeExecutable, spawnClaudeCodeWithDiagnostics, wrapClaudeSpawnErrorForSdk, type ClaudeExecutableFileType, type ClaudeExecutablePreflightResult } from "./claude-executable.js";
|
|
51
|
-
export { __testGetBridgeIntegrityState, __testSetBridgeIntegrityState, reportToolResultMismatch } from "./bridge-state.js";
|
|
52
|
-
export {
|
|
57
|
+
export { __testGetBridgeIntegrityState, __testSetBridgeIntegrityState, INTEGRITY_CUSTOM_TYPE, appendIntegrityEntry, reportToolResultMismatch } from "./bridge-state.js";
|
|
58
|
+
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";
|
|
53
60
|
export { restoreSharedSessionFromPi, shouldRestorePersistedBridgeEntry } from "./session-persistence.js";
|
|
61
|
+
export { NATIVE_PROVIDER_UNSUPPORTED_MESSAGE, buildNativeProvider, claudeAuthSourceLabel, supportsNativeProvider } from "./native-provider.js";
|
|
54
62
|
export { DEFAULT_STREAM_IDLE_TIMEOUT_MS, STREAM_IDLE_BACKOFF_HINT_MS, STREAM_IDLE_TIMEOUT_ENV, buildStreamIdleTimeoutErrorMessage, createStreamIdleWatchdog, streamIdleTimeoutMsFromEnv, type StreamIdleTimeoutInfo, type StreamIdleWatchdog, type StreamIdleWatchdogState } from "./stream-idle-watchdog.js";
|
|
55
|
-
export { ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD, formatAllowedRateLimitWarning, formatResetTimestamp, isExtraUsageRequiredMessage, normalizeRateLimitUtilization, uniqueNonEmptyLines } from "./rate-limit.js";
|
|
63
|
+
export { ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD, formatAllowedRateLimitWarning, formatResetTimestamp, isExtraUsageRequiredMessage, isUsageLimitMessage, normalizeRateLimitUtilization, resetTimestampMs, uniqueNonEmptyLines } from "./rate-limit.js";
|
|
56
64
|
export { mapToolName } from "./tool-mapping.js";
|
|
57
|
-
export { processAssistantMessage, processStreamEvent } from "./assistant-stream.js";
|
|
65
|
+
export { cancelScheduledToolUseEnd, endToolUseTurn, finalizeToolUseTurnFromMcpInvocation, noteChildExecutedToolResults, processAssistantMessage, processStreamEvent, reapStaleQueuedResults, scheduleToolUseTurnEnd } from "./assistant-stream.js";
|
|
58
66
|
|
|
59
67
|
// Compat (#2): use factory if available (pi-ai ≥0.66), else fall back to constructor (gsd-pi etc.)
|
|
60
68
|
const _piAi = piAi as any;
|
|
@@ -89,7 +97,7 @@ const newAssistantMessageEventStream: () => AssistantMessageEventStream =
|
|
|
89
97
|
//
|
|
90
98
|
// Both are released on session_shutdown (incl. /reload) by releaseProviderTokens
|
|
91
99
|
// so the next module load starts clean. See applyProviderRegistration for the
|
|
92
|
-
//
|
|
100
|
+
// native (pi >=0.81) upsert flow.
|
|
93
101
|
const PRIMARY_INSTANCE_KEY = Symbol.for("claude-bridge:primaryInstance");
|
|
94
102
|
const ACTIVE_STREAM_SIMPLE_KEY = Symbol.for("claude-bridge:activeStreamSimple");
|
|
95
103
|
const COMMANDS_REGISTERED_KEY = Symbol.for("claude-bridge:commandsRegistered");
|
|
@@ -111,6 +119,35 @@ function extraUsageAllowed(config: Config): boolean {
|
|
|
111
119
|
return config.provider?.allowExtraUsage === true;
|
|
112
120
|
}
|
|
113
121
|
|
|
122
|
+
// The fastMode setting silently no-ops when Claude Code declines fast mode.
|
|
123
|
+
// Surface the typed fast_mode_disabled_reason (SDK 0.3.219+) once per distinct
|
|
124
|
+
// reason so an enabled-but-inert setting explains itself instead of looking
|
|
125
|
+
// broken. Module-level dedup: the same reason repeats on every init message.
|
|
126
|
+
let lastFastModeDisabledNoticeReason: string | null = null;
|
|
127
|
+
|
|
128
|
+
const FAST_MODE_DISABLED_REASON_TEXT: Record<string, string> = {
|
|
129
|
+
disabled_by_env: "disabled by an environment variable",
|
|
130
|
+
extra_usage_disabled: "extra usage is disabled for this account",
|
|
131
|
+
free: "not available on the free plan",
|
|
132
|
+
model_not_allowed: "not available for this model",
|
|
133
|
+
network_error: "the eligibility check hit a network error",
|
|
134
|
+
not_first_party: "not available for this account type",
|
|
135
|
+
preference: "disabled by a Claude Code preference",
|
|
136
|
+
sdk_opt_in_required: "the SDK opt-in is missing",
|
|
137
|
+
unknown: "unavailable for an unknown reason",
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
function noteFastModeDisabledReason(message: unknown, bridgeConfig: Config): void {
|
|
141
|
+
if (bridgeConfig.provider?.fastMode !== true) return;
|
|
142
|
+
const reason = (message as { fast_mode_disabled_reason?: unknown }).fast_mode_disabled_reason;
|
|
143
|
+
// "pending" means the CLI is still deciding — not a verdict worth announcing.
|
|
144
|
+
if (typeof reason !== "string" || reason === "pending") return;
|
|
145
|
+
if (reason === lastFastModeDisabledNoticeReason) return;
|
|
146
|
+
lastFastModeDisabledNoticeReason = reason;
|
|
147
|
+
const text = FAST_MODE_DISABLED_REASON_TEXT[reason] ?? `unavailable (${reason})`;
|
|
148
|
+
safeNotify(`Claude bridge: fast mode is enabled in settings but Claude Code declined it — ${text}.`, "warning");
|
|
149
|
+
}
|
|
150
|
+
|
|
114
151
|
function sdkTextFromMessage(message: SDKMessage): string | undefined {
|
|
115
152
|
if (message.type === "result") return (message as any).result;
|
|
116
153
|
if (message.type === "assistant") {
|
|
@@ -243,7 +280,7 @@ async function* wrapPromptStream(blocks: ContentBlockParam[]): AsyncIterable<SDK
|
|
|
243
280
|
// them without activating the extension. `ctx()`, `pushContext()`, `popContext()`
|
|
244
281
|
// are imported at the top of this file.
|
|
245
282
|
|
|
246
|
-
function resolveMcpTools(context: Context, excludeToolName?: string): {
|
|
283
|
+
export function resolveMcpTools(context: Context, excludeToolName?: string): {
|
|
247
284
|
mcpTools: Tool[];
|
|
248
285
|
customToolNameToSdk: Map<string, string>;
|
|
249
286
|
customToolNameToPi: Map<string, string>;
|
|
@@ -256,10 +293,29 @@ function resolveMcpTools(context: Context, excludeToolName?: string): {
|
|
|
256
293
|
|
|
257
294
|
for (const tool of context.tools) {
|
|
258
295
|
if (tool.name === excludeToolName) continue;
|
|
296
|
+
// Never re-offer a tool the child owns natively. The claude.ai connector
|
|
297
|
+
// namespace belongs to the child's own MCP servers, so a Pi tool sitting
|
|
298
|
+
// on it would be advertised a SECOND time under our prefix — two names
|
|
299
|
+
// for one capability, and the model picking the wrong one gets a real
|
|
300
|
+
// `Tool ... not found` from the dispatcher (memsira#320). It would also
|
|
301
|
+
// be uncallable in any case: a `tool_use` under that namespace is treated
|
|
302
|
+
// as child-executed and never handed to Pi (isChildExecutedTool), so
|
|
303
|
+
// filtering here is what makes the two halves agree end to end.
|
|
304
|
+
if (isChildExecutedTool(tool.name)) {
|
|
305
|
+
debug(`resolveMcpTools: not re-offering child-native tool ${tool.name}`);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
259
308
|
const sdkName = `${MCP_TOOL_PREFIX}${tool.name}`;
|
|
260
309
|
mcpTools.push(tool);
|
|
310
|
+
// Case-insensitive aliases mean two tools differing only by case would
|
|
311
|
+
// silently overwrite each other's mapping — surface it if it ever happens.
|
|
312
|
+
const lowerName = tool.name.toLowerCase();
|
|
313
|
+
const collision = customToolNameToSdk.get(lowerName);
|
|
314
|
+
if (collision !== undefined && collision !== sdkName) {
|
|
315
|
+
debug(`WARNING: resolveMcpTools lowercase alias collision: ${tool.name} overwrites mapping previously held by ${collision}`);
|
|
316
|
+
}
|
|
261
317
|
customToolNameToSdk.set(tool.name, sdkName);
|
|
262
|
-
customToolNameToSdk.set(
|
|
318
|
+
customToolNameToSdk.set(lowerName, sdkName);
|
|
263
319
|
customToolNameToPi.set(sdkName, tool.name);
|
|
264
320
|
customToolNameToPi.set(sdkName.toLowerCase(), tool.name);
|
|
265
321
|
}
|
|
@@ -267,54 +323,12 @@ function resolveMcpTools(context: Context, excludeToolName?: string): {
|
|
|
267
323
|
return { mcpTools, customToolNameToSdk, customToolNameToPi };
|
|
268
324
|
}
|
|
269
325
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
* ever arrives. The invocation itself proves the assistant turn is committed,
|
|
277
|
-
* so end the pi stream here exactly like the `message_stop` path; otherwise
|
|
278
|
-
* the handler blocks on a result pi will never deliver (deadlock). No-op when
|
|
279
|
-
* the turn already ended (stream null) or the tool call isn't part of the
|
|
280
|
-
* currently streamed turn. */
|
|
281
|
-
function finalizeToolUseTurnFromMcpInvocation(
|
|
282
|
-
queryCtx: QueryContext,
|
|
283
|
-
toolCallId: string,
|
|
284
|
-
toolName: string,
|
|
285
|
-
mappedArgs: Record<string, unknown>,
|
|
286
|
-
): void {
|
|
287
|
-
if (!queryCtx.currentPiStream || !queryCtx.turnOutput) return;
|
|
288
|
-
let idx = queryCtx.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === toolCallId);
|
|
289
|
-
if (idx >= 0) {
|
|
290
|
-
const block = queryCtx.turnBlocks[idx] as any;
|
|
291
|
-
if ("partialJson" in block) {
|
|
292
|
-
// Stream ended before content_block_stop — settle the args from the
|
|
293
|
-
// partial JSON the same way content_block_stop would have.
|
|
294
|
-
block.arguments = mapToolArgs(block.name, parsePartialJson(block.partialJson, block.arguments));
|
|
295
|
-
queryCtx.updateToolCallArgs(block.id, block.arguments);
|
|
296
|
-
delete block.partialJson;
|
|
297
|
-
delete block.index;
|
|
298
|
-
queryCtx.currentPiStream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: queryCtx.turnOutput });
|
|
299
|
-
}
|
|
300
|
-
} else {
|
|
301
|
-
// The invocation can arrive before the tool_use is streamed at all
|
|
302
|
-
// (observed after a tool-result+steer provider call reset the turn):
|
|
303
|
-
// synthesize the toolCall from the claim — the MCP call carries the
|
|
304
|
-
// authoritative id, name, and arguments.
|
|
305
|
-
queryCtx.turnBlocks.push({ type: "toolCall", id: toolCallId, name: toolName, arguments: mappedArgs });
|
|
306
|
-
idx = queryCtx.turnBlocks.length - 1;
|
|
307
|
-
const block = queryCtx.turnBlocks[idx] as any;
|
|
308
|
-
queryCtx.currentPiStream.push({ type: "toolcall_start", contentIndex: idx, partial: queryCtx.turnOutput });
|
|
309
|
-
queryCtx.currentPiStream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: queryCtx.turnOutput });
|
|
310
|
-
}
|
|
311
|
-
queryCtx.turnSawToolCall = true;
|
|
312
|
-
queryCtx.turnOutput.stopReason = "toolUse";
|
|
313
|
-
debug(`mcp handler: finalizing tool_use turn from MCP invocation [${toolCallId}] (${toolName}) — SDK invoked the tool before message_stop/assistant message`);
|
|
314
|
-
queryCtx.currentPiStream.push({ type: "done", reason: "toolUse", message: queryCtx.turnOutput });
|
|
315
|
-
queryCtx.currentPiStream.end();
|
|
316
|
-
queryCtx.currentPiStream = null;
|
|
317
|
-
}
|
|
326
|
+
// finalizeToolUseTurnFromMcpInvocation moved to assistant-stream.ts: it is now
|
|
327
|
+
// the grace-timer ACTION armed by scheduleToolUseTurnEnd rather than an
|
|
328
|
+
// immediate end. The CLI invokes MCP handlers before message_delta arrives on
|
|
329
|
+
// every tool-use turn, and message_delta is what carries the real output-token
|
|
330
|
+
// count — ending the pi stream at handler invocation is what froze pi's
|
|
331
|
+
// per-turn output figures at the message_start placeholders (1–7 tokens).
|
|
318
332
|
|
|
319
333
|
// Creates an MCP server that bridges pi tools to the SDK. Each tool handler
|
|
320
334
|
// blocks on a Promise until pi delivers the tool result via streamSimple.
|
|
@@ -341,9 +355,25 @@ function buildMcpServers(tools: Tool[], queryCtx: QueryContext): Record<string,
|
|
|
341
355
|
turnToolCallIds: queryCtx.turnToolCallIds,
|
|
342
356
|
turnToolCalls: safeToolCallSummary(queryCtx.turnToolCalls),
|
|
343
357
|
});
|
|
358
|
+
appendIntegrityEntry("tool_handler_unmatched", {
|
|
359
|
+
toolName: tool.name,
|
|
360
|
+
argKeys: argKeys(mappedArgs),
|
|
361
|
+
available: claim.available,
|
|
362
|
+
turnToolCallIds: queryCtx.turnToolCallIds,
|
|
363
|
+
});
|
|
344
364
|
return { content: [{ type: "text", text: `Claude bridge internal error: no matching tool_call id for ${tool.name}` }], isError: true } satisfies McpResult;
|
|
345
365
|
}
|
|
346
|
-
if (claim.
|
|
366
|
+
if (claim.argsMismatch) {
|
|
367
|
+
// Claimed anyway (sole same-name candidate) — record the divergence so
|
|
368
|
+
// a schema/validator drift stays visible without stranding the call.
|
|
369
|
+
debug(`mcp handler: ${tool.name} [${toolCallId}] claimed sole same-name call despite args mismatch`);
|
|
370
|
+
diagDump("tool_claim_args_mismatch", {
|
|
371
|
+
toolName: tool.name,
|
|
372
|
+
toolCallId,
|
|
373
|
+
handlerArgKeys: argKeys(mappedArgs),
|
|
374
|
+
recordedArgKeys: argKeys(queryCtx.turnToolCalls.find((call) => call.id === toolCallId)?.arguments),
|
|
375
|
+
});
|
|
376
|
+
} else if (claim.match !== "tool-args" || claim.ambiguous) {
|
|
347
377
|
debug(`mcp handler: ${tool.name} [${toolCallId}] claimed by ${claim.match}${claim.ambiguous ? " (ambiguous)" : ""}`);
|
|
348
378
|
}
|
|
349
379
|
if (toolCallId && queryCtx.pendingResults.has(toolCallId)) {
|
|
@@ -354,7 +384,14 @@ function buildMcpServers(tools: Tool[], queryCtx: QueryContext): Record<string,
|
|
|
354
384
|
return result;
|
|
355
385
|
}
|
|
356
386
|
debug(`mcp handler: ${tool.name} [${toolCallId}] → waiting`);
|
|
357
|
-
|
|
387
|
+
// Don't end the pi turn here — message_delta (real output tokens) and
|
|
388
|
+
// message_stop are normally milliseconds behind this invocation. Arm the
|
|
389
|
+
// grace timer instead; it force-finalizes only if they never arrive.
|
|
390
|
+
scheduleToolUseTurnEnd(
|
|
391
|
+
queryCtx,
|
|
392
|
+
() => finalizeToolUseTurnFromMcpInvocation(queryCtx, toolCallId, tool.name, mappedArgs),
|
|
393
|
+
`mcp-invocation:${tool.name}`,
|
|
394
|
+
);
|
|
358
395
|
return new Promise<McpResult>((resolve) => {
|
|
359
396
|
queryCtx.pendingToolCalls.set(toolCallId, {
|
|
360
397
|
toolName: tool.name,
|
|
@@ -443,20 +480,38 @@ async function consumeQuery(
|
|
|
443
480
|
break;
|
|
444
481
|
case "result":
|
|
445
482
|
if (!ctx().turnSawStreamEvent && message.subtype === "success") {
|
|
446
|
-
ensureTurnStarted();
|
|
447
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();
|
|
448
492
|
ctx().turnBlocks.push({ type: "text", text });
|
|
449
493
|
const idx = ctx().turnBlocks.length - 1;
|
|
450
494
|
ctx().currentPiStream?.push({ type: "text_start", contentIndex: idx, partial: ctx().turnOutput });
|
|
451
495
|
ctx().currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: text, partial: ctx().turnOutput });
|
|
452
496
|
ctx().currentPiStream?.push({ type: "text_end", contentIndex: idx, content: text, partial: ctx().turnOutput });
|
|
453
|
-
} else if (message.subtype !== "success" && isExtraUsageRequiredMessage(message)) {
|
|
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.
|
|
454
503
|
const errorLines = Array.isArray((message as any).errors) ? uniqueNonEmptyLines((message as any).errors) : [];
|
|
455
504
|
const errors = errorLines.length > 0 ? errorLines.join("\n") : String(message.subtype ?? "Claude Code rate limit");
|
|
456
|
-
const
|
|
505
|
+
const extraUsage = isExtraUsageRequiredMessage(message);
|
|
506
|
+
const openedExtraUsage = extraUsage && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "result error");
|
|
457
507
|
ctx().handledTerminalError = true;
|
|
458
508
|
ctx().turnOutput.stopReason = "error";
|
|
459
|
-
|
|
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}`;
|
|
460
515
|
ctx().currentPiStream?.push({ type: "error", reason: "error", error: ctx().turnOutput });
|
|
461
516
|
ctx().currentPiStream?.end();
|
|
462
517
|
ctx().currentPiStream = null;
|
|
@@ -465,6 +520,11 @@ async function consumeQuery(
|
|
|
465
520
|
case "system":
|
|
466
521
|
if ((message as any).subtype === "init" && (message as any).session_id) {
|
|
467
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);
|
|
468
528
|
} else if ((message as any).subtype === "model_refusal_fallback") {
|
|
469
529
|
const originalModel = (message as any).original_model;
|
|
470
530
|
const fallbackModel = (message as any).fallback_model;
|
|
@@ -481,13 +541,17 @@ async function consumeQuery(
|
|
|
481
541
|
}
|
|
482
542
|
break;
|
|
483
543
|
case "user":
|
|
484
|
-
|
|
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;
|
|
485
549
|
case "rate_limit_event": {
|
|
486
550
|
const info = (message as any).rate_limit_info;
|
|
487
551
|
debug("consumeQuery: rate_limit_event", JSON.stringify(info).slice(0, 300));
|
|
488
552
|
if (info?.status === "rejected") {
|
|
489
553
|
const resetsAt = formatResetTimestamp(info.resetsAt);
|
|
490
|
-
const resetAtMs =
|
|
554
|
+
const resetAtMs = resetTimestampMs(info.resetsAt);
|
|
491
555
|
const reason = `${info.rateLimitType ?? "unknown"} rate limit`;
|
|
492
556
|
const launchedExtraUsage = isExtraUsageRequiredMessage(info) && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, reason);
|
|
493
557
|
emitRateLimitEvent({
|
|
@@ -532,10 +596,10 @@ function claimPrimaryInstance(): boolean {
|
|
|
532
596
|
|
|
533
597
|
// Release both process-global tokens this instance owns. Called on
|
|
534
598
|
// session_shutdown (incl. /reload) so the freshly loaded instance starts clean.
|
|
535
|
-
// NOTE: this does NOT unregister the provider —
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
599
|
+
// NOTE: this does NOT unregister the provider — pi's provider registry is
|
|
600
|
+
// process-lifetime state that survives module reload; the next loaded instance
|
|
601
|
+
// simply upserts its own provider object over ours (registerNativeProvider is
|
|
602
|
+
// replace-by-id), and logout-hiding is the provider's own auth check.
|
|
539
603
|
function releaseProviderTokens(event: string): void {
|
|
540
604
|
const g = globalThis as Record<symbol, any>;
|
|
541
605
|
if (g[ACTIVE_STREAM_SIMPLE_KEY] === streamClaudeAgentSdk) {
|
|
@@ -548,61 +612,66 @@ function releaseProviderTokens(event: string): void {
|
|
|
548
612
|
}
|
|
549
613
|
}
|
|
550
614
|
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
// (fail-fast) so a `claude login` / logout is reflected without a /reload.
|
|
615
|
+
// Native (pi >=0.81) provider registration. Run at extension load, on every
|
|
616
|
+
// session_start, and at pre-spawn.
|
|
554
617
|
//
|
|
555
|
-
//
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
//
|
|
618
|
+
// 2.x registers UNCONDITIONALLY (once primary): credential-driven availability
|
|
619
|
+
// is the provider's own auth.check/resolve reporting configured-ness, so pi
|
|
620
|
+
// hides/shows claude-bridge models itself — the 1.x register/unregister state
|
|
621
|
+
// machine (decideRegistration) is gone. What each trigger does now:
|
|
622
|
+
// - load: build + register the provider (queued by the loader until bindCore).
|
|
623
|
+
// - session_start: re-upsert the SAME provider object. registerNativeProvider
|
|
624
|
+
// is upsert-by-id and kicks pi's model-snapshot/availability refresh, so a
|
|
625
|
+
// `claude login`/logout since the last session boundary is reflected
|
|
626
|
+
// deterministically — the same guarantee the 1.x re-check gave — without
|
|
627
|
+
// depending on pi's own refresh cadence.
|
|
628
|
+
// - pre-spawn: same re-upsert, from the fail-fast path, so a mid-session
|
|
629
|
+
// logout also flips availability at first use.
|
|
630
|
+
// Non-primary instances (subagents) never touch registration: pi's native
|
|
631
|
+
// registry REPLACES by id, so an unguarded subagent re-register would swap in
|
|
632
|
+
// its own streamSimple — the exact split-brain the tokens exist to prevent.
|
|
633
|
+
// On a pre-0.81 host the extension declines loudly (once) instead of
|
|
634
|
+
// registering wrongly through the legacy overload.
|
|
635
|
+
let nativeProviderInstance: unknown;
|
|
636
|
+
let notifiedNativeUnsupported = false;
|
|
637
|
+
|
|
569
638
|
function applyProviderRegistration(trigger: string): void {
|
|
570
639
|
const pi = extensionApi;
|
|
571
640
|
if (!pi) { debug(`${trigger}: applyProviderRegistration skipped — no extensionApi`); return; }
|
|
572
641
|
const g = globalThis as Record<symbol, any>;
|
|
573
642
|
const isPrimary = claimPrimaryInstance();
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
if (
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
pi.registerProvider(PROVIDER_ID, {
|
|
584
|
-
baseUrl: "claude-bridge",
|
|
585
|
-
apiKey: "not-used",
|
|
586
|
-
api: "claude-bridge",
|
|
587
|
-
models: MODELS,
|
|
588
|
-
// Cast: pi-ai AssistantMessageEventStream diamond dep between pi-coding-agent and pi-agent-core
|
|
589
|
-
streamSimple: streamClaudeAgentSdk as any,
|
|
590
|
-
});
|
|
591
|
-
} catch (err) {
|
|
592
|
-
// Self-heal: release ONLY the stream guard we just claimed so a later
|
|
593
|
-
// re-check (primary + credentialed + not-registered → register) retries.
|
|
594
|
-
// Keep PRIMARY_INSTANCE_KEY: releasing it would reopen the subagent
|
|
595
|
-
// ownership-steal window, and retry does not need it released.
|
|
596
|
-
if (g[ACTIVE_STREAM_SIMPLE_KEY] === streamClaudeAgentSdk) g[ACTIVE_STREAM_SIMPLE_KEY] = undefined;
|
|
597
|
-
debug(`${trigger}: registerProvider threw; released stream guard for retry (kept primary):`, err);
|
|
598
|
-
}
|
|
599
|
-
} else if (decision === "unregister") {
|
|
600
|
-
try {
|
|
601
|
-
pi.unregisterProvider(PROVIDER_ID);
|
|
602
|
-
} catch (err) {
|
|
603
|
-
debug(`${trigger}: unregisterProvider threw (ignored):`, err);
|
|
643
|
+
if (!isPrimary) {
|
|
644
|
+
debug(`${trigger}: registration noop — non-primary instance (module=${moduleInstanceId})`);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (!supportsNativeProvider(_piAi)) {
|
|
648
|
+
debug(`${trigger}: host pi-ai lacks createProvider; refusing to register (module=${moduleInstanceId})`);
|
|
649
|
+
if (!notifiedNativeUnsupported) {
|
|
650
|
+
notifiedNativeUnsupported = true;
|
|
651
|
+
safeNotify(NATIVE_PROVIDER_UNSUPPORTED_MESSAGE, "error");
|
|
604
652
|
}
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const credentialed = hasClaudeCredentials();
|
|
656
|
+
debug(`${trigger}: native registration upsert, credentialed=${credentialed} (module=${moduleInstanceId})`);
|
|
657
|
+
// Start the connector inventory now, not on the first turn: the query path
|
|
658
|
+
// can only read a synchronous snapshot, so priming here is what gets the
|
|
659
|
+
// declarations in place before turn 1 (vstack#832). Fire and forget —
|
|
660
|
+
// registration must not wait on the network. Only worth it when a Claude
|
|
661
|
+
// account is actually connected.
|
|
662
|
+
if (credentialed && connectorsEnabledFor(loadConfig(process.cwd()))) primeConnectorServers();
|
|
663
|
+
// Claim ordering: stream guard BEFORE registerProvider so a concurrent
|
|
664
|
+
// subagent can never observe a registered provider without an owner.
|
|
665
|
+
g[ACTIVE_STREAM_SIMPLE_KEY] = streamClaudeAgentSdk;
|
|
666
|
+
try {
|
|
667
|
+
nativeProviderInstance ??= buildNativeProvider(_piAi, MODELS, streamClaudeAgentSdk as (...args: unknown[]) => unknown);
|
|
668
|
+
(pi.registerProvider as (provider: unknown) => void)(nativeProviderInstance);
|
|
669
|
+
} catch (err) {
|
|
670
|
+
// Self-heal: release ONLY the stream guard we just claimed so a later
|
|
671
|
+
// re-check retries cleanly. Keep PRIMARY_INSTANCE_KEY: releasing it would
|
|
672
|
+
// reopen the subagent ownership-steal window.
|
|
605
673
|
if (g[ACTIVE_STREAM_SIMPLE_KEY] === streamClaudeAgentSdk) g[ACTIVE_STREAM_SIMPLE_KEY] = undefined;
|
|
674
|
+
debug(`${trigger}: registerProvider threw; released stream guard for retry (kept primary):`, err);
|
|
606
675
|
}
|
|
607
676
|
}
|
|
608
677
|
|
|
@@ -708,12 +777,12 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
708
777
|
// Fail-fast credential re-check (only for a fresh query — NEVER for
|
|
709
778
|
// tool-result delivery of an in-flight query, handled above, where creds were
|
|
710
779
|
// valid at start and failing mid-turn would break tool pairing). This bounds
|
|
711
|
-
// the
|
|
712
|
-
// if credentials vanished since the last session_start, (a)
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
780
|
+
// the logout-visibility window from "next session boundary" to "first use":
|
|
781
|
+
// if credentials vanished since the last session_start, (a) re-upsert the
|
|
782
|
+
// provider (primary-only) so pi's availability recompute hides the models,
|
|
783
|
+
// and (b) fail this request with a clear, actionable message instead of
|
|
784
|
+
// letting the SDK spawn die with a generic error. The check is cheap
|
|
785
|
+
// (existsSync + env reads only, no credential contents).
|
|
717
786
|
if (!hasClaudeCredentials()) {
|
|
718
787
|
try { applyProviderRegistration("pre-spawn"); } catch { /* best effort */ }
|
|
719
788
|
const message = "Claude account not connected — connect an account (or run `claude login`) and retry.";
|
|
@@ -781,6 +850,10 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
781
850
|
// Connector WRITE control: read-only by default (writes denied); the one-shot
|
|
782
851
|
// approved-write executor sets CLAUDE_BRIDGE_CONNECTOR_WRITE=allow / config.
|
|
783
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() : {};
|
|
784
857
|
const appendSystemPrompt = providerSettings.appendSystemPrompt !== false;
|
|
785
858
|
const agentsAppend = appendSystemPrompt ? extractAgentsAppend() : undefined;
|
|
786
859
|
const skillsAppend = appendSystemPrompt ? extractSkillsBlock(context.systemPrompt) : undefined;
|
|
@@ -817,9 +890,12 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
817
890
|
const effort = resolveConfiguredEffort(model.id, requestedEffort, providerSettings);
|
|
818
891
|
|
|
819
892
|
const extraArgs: Record<string, string | null> = {};
|
|
820
|
-
if (strictMcpConfigEnabled) extraArgs["strict-mcp-config"] = null;
|
|
821
893
|
// Opus 4.7 defaults thinking.display to "omitted" (empty thinking text in stream).
|
|
822
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`.
|
|
823
899
|
if (effort) extraArgs["thinking-display"] = "summarized";
|
|
824
900
|
const fallbackModel = fallbackModelForPrimaryModel(model.id);
|
|
825
901
|
|
|
@@ -850,9 +926,12 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
850
926
|
append: systemPromptAppend ? systemPromptAppend : undefined,
|
|
851
927
|
},
|
|
852
928
|
extraArgs,
|
|
929
|
+
...(strictMcpConfigEnabled ? { strictMcpConfig: true } : {}),
|
|
853
930
|
...(effort ? { effort } : {}),
|
|
854
931
|
...(settingSources ? { settingSources } : {}),
|
|
855
|
-
...(mcpServers
|
|
932
|
+
...(mcpServers || Object.keys(connectorServers).length > 0
|
|
933
|
+
? { mcpServers: { ...(mcpServers ?? {}), ...connectorServers } as NonNullable<Parameters<typeof query>[0]["options"]>["mcpServers"] }
|
|
934
|
+
: {}),
|
|
856
935
|
...(resumeSessionId ? { resume: resumeSessionId } : {}),
|
|
857
936
|
...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
|
|
858
937
|
spawnClaudeCodeProcess: spawnClaudeCodeWithDiagnostics,
|
|
@@ -948,10 +1027,15 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
948
1027
|
else options.signal.addEventListener("abort", onAbort, { once: true });
|
|
949
1028
|
}
|
|
950
1029
|
|
|
951
|
-
// Background consumer — runs until query ends
|
|
1030
|
+
// Background consumer — runs until query ends.
|
|
1031
|
+
// The handlers below use the CAPTURED abortCtx, never the live ctx(): the two
|
|
1032
|
+
// only differ while a reentrant (subagent) context is pushed, and a parent
|
|
1033
|
+
// query CAN end in that window (abort, child process death throwing out of
|
|
1034
|
+
// the generator). Live-ctx handlers there mutated the subagent's turn state
|
|
1035
|
+
// and stream and skipped the parent's own teardown entirely.
|
|
952
1036
|
consumeQuery(sdkQuery, customToolNameToPi, model, cwd, bridgeConfig, () => wasAborted)
|
|
953
1037
|
.then(async ({ capturedSessionId }) => {
|
|
954
|
-
debug(`provider: consumeQuery completed, stopReason=${
|
|
1038
|
+
debug(`provider: consumeQuery completed, stopReason=${abortCtx.turnOutput?.stopReason}, error=${abortCtx.turnOutput?.errorMessage}, aborted=${wasAborted}`);
|
|
955
1039
|
if (streamIdleTimedOut) {
|
|
956
1040
|
abortCtx.deferredUserMessages = [];
|
|
957
1041
|
debug("provider: stream idle timeout already surfaced; skipping normal completion");
|
|
@@ -961,22 +1045,22 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
961
1045
|
// --- Abort detection in normal completion path ---
|
|
962
1046
|
if (wasAborted || options?.signal?.aborted) {
|
|
963
1047
|
if (sharedSession) setSharedSession({ ...sharedSession, needsRebuild: true, forceRotate: true });
|
|
964
|
-
|
|
1048
|
+
abortCtx.deferredUserMessages = [];
|
|
965
1049
|
debug(`provider: abort detected, marked sharedSession needsRebuild + forceRotate`);
|
|
966
|
-
if (
|
|
967
|
-
|
|
968
|
-
|
|
1050
|
+
if (abortCtx.turnOutput) {
|
|
1051
|
+
abortCtx.turnOutput.stopReason = "aborted";
|
|
1052
|
+
abortCtx.turnOutput.errorMessage = "Operation aborted";
|
|
969
1053
|
}
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1054
|
+
abortCtx.currentPiStream?.push({ type: "error", reason: "aborted", error: abortCtx.turnOutput! });
|
|
1055
|
+
abortCtx.currentPiStream?.end();
|
|
1056
|
+
abortCtx.currentPiStream = null;
|
|
973
1057
|
return;
|
|
974
1058
|
}
|
|
975
1059
|
|
|
976
1060
|
// --- Capture session ID ---
|
|
977
1061
|
const sessionId = capturedSessionId ?? sharedSession?.sessionId;
|
|
978
1062
|
if (sessionId) {
|
|
979
|
-
const cursor = Math.max(context.messages.length,
|
|
1063
|
+
const cursor = Math.max(context.messages.length, abortCtx.latestCursor, sharedSession?.cursor ?? 0);
|
|
980
1064
|
debug(`provider: query done, session=${sessionId.slice(0, 8)}, cursor=${cursor}`);
|
|
981
1065
|
setSharedSession({ sessionId, cursor, cwd });
|
|
982
1066
|
}
|
|
@@ -985,11 +1069,11 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
985
1069
|
// Only for outermost queries — reentrant (subagent) queries leave
|
|
986
1070
|
// deferred messages for the parent to handle after it finishes.
|
|
987
1071
|
try {
|
|
988
|
-
while (
|
|
989
|
-
const steerPrompt =
|
|
1072
|
+
while (abortCtx.deferredUserMessages.length > 0 && !isReentrant && !wasAborted) {
|
|
1073
|
+
const steerPrompt = abortCtx.deferredUserMessages.shift()!;
|
|
990
1074
|
debug(`provider: replaying deferred user message: ${steerPrompt.slice(0, 60)}`);
|
|
991
|
-
|
|
992
|
-
|
|
1075
|
+
abortCtx.resetTurnState(model);
|
|
1076
|
+
abortCtx.resetToolTracking();
|
|
993
1077
|
|
|
994
1078
|
const resumeId = sharedSession?.sessionId;
|
|
995
1079
|
if (!resumeId) {
|
|
@@ -999,7 +1083,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
999
1083
|
|
|
1000
1084
|
const contOptions = { ...queryOptions, resume: resumeId, ...makeCliDebugOptions("continuation") };
|
|
1001
1085
|
const contQuery = query({ prompt: steerPrompt, options: contOptions });
|
|
1002
|
-
|
|
1086
|
+
abortCtx.activeQuery = contQuery;
|
|
1003
1087
|
|
|
1004
1088
|
debug(`provider: continuation query, model=${model.id}, resume=${resumeId.slice(0, 8)}, prompt=${steerPrompt.slice(0, 60)}`);
|
|
1005
1089
|
|
|
@@ -1018,52 +1102,39 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1018
1102
|
}
|
|
1019
1103
|
} finally {
|
|
1020
1104
|
// Guarantees restoration even if contQuery() throws synchronously
|
|
1021
|
-
|
|
1105
|
+
abortCtx.activeQuery = sdkQuery;
|
|
1022
1106
|
}
|
|
1023
1107
|
|
|
1024
|
-
finalizeCurrentStream(
|
|
1108
|
+
finalizeCurrentStream(abortCtx.turnOutput?.stopReason, abortCtx);
|
|
1025
1109
|
})
|
|
1026
1110
|
.catch((error) => {
|
|
1027
1111
|
debug(`provider: query error, model=${model.id}, aborted=${Boolean(options?.signal?.aborted)}, error=`, error);
|
|
1028
|
-
const suppressDuplicateError =
|
|
1112
|
+
const suppressDuplicateError = abortCtx.handledTerminalError || streamIdleTimedOut;
|
|
1029
1113
|
const openedExtraUsage = !suppressDuplicateError && isExtraUsageRequiredMessage(error) && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "query error");
|
|
1030
1114
|
if ((wasAborted || options?.signal?.aborted) && sharedSession) {
|
|
1031
1115
|
setSharedSession({ ...sharedSession, needsRebuild: true, forceRotate: true });
|
|
1032
1116
|
} else {
|
|
1033
1117
|
setSharedSession(null);
|
|
1034
1118
|
}
|
|
1035
|
-
|
|
1119
|
+
abortCtx.deferredUserMessages = [];
|
|
1036
1120
|
if (suppressDuplicateError) {
|
|
1037
1121
|
debug("provider: suppressing duplicate query error after terminal error was already emitted");
|
|
1038
1122
|
return;
|
|
1039
1123
|
}
|
|
1040
|
-
if (
|
|
1041
|
-
|
|
1042
|
-
|
|
1124
|
+
if (abortCtx.turnOutput) {
|
|
1125
|
+
abortCtx.turnOutput.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
1126
|
+
abortCtx.turnOutput.errorMessage = `${error instanceof Error ? error.message : String(error)}${openedExtraUsage ? "\n\nOpened Claude Code /extra-usage helper. Complete billing/admin flow in the browser, then retry the prompt." : ""}`;
|
|
1043
1127
|
}
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1128
|
+
abortCtx.currentPiStream?.push({ type: "error", reason: (abortCtx.turnOutput?.stopReason ?? "error") as "aborted" | "error", error: abortCtx.turnOutput! });
|
|
1129
|
+
abortCtx.currentPiStream?.end();
|
|
1130
|
+
abortCtx.currentPiStream = null;
|
|
1047
1131
|
})
|
|
1048
1132
|
.finally(() => {
|
|
1049
1133
|
streamIdleWatchdog?.dispose();
|
|
1050
1134
|
activeStreamIdleWatchdogs.delete(abortCtx);
|
|
1051
1135
|
if (options?.signal) options.signal.removeEventListener("abort", onAbort);
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
reportToolResultMismatch(ctx(), "query teardown", cwd, { forceRotate: cause !== "query-end" });
|
|
1055
|
-
// Drain pending handlers for this query as errors naming the cause —
|
|
1056
|
-
// their results are never coming.
|
|
1057
|
-
const drained = drainPendingToolCalls(ctx(), cause);
|
|
1058
|
-
if (drained > 0) debug(`provider: query teardown drained ${drained} waiting MCP handler(s) as errors (cause=${cause})`);
|
|
1059
|
-
ctx().pendingResults.clear();
|
|
1060
|
-
|
|
1061
|
-
if (isReentrant) {
|
|
1062
|
-
popContext(); // merges deferred messages and restores parent
|
|
1063
|
-
} else {
|
|
1064
|
-
ctx().activeQuery = null;
|
|
1065
|
-
}
|
|
1066
|
-
}
|
|
1136
|
+
const cause = toolCallDrainCause({ wasAborted, signalAborted: options?.signal?.aborted, streamIdleTimedOut });
|
|
1137
|
+
teardownQuery(abortCtx, sdkQuery, cause, cwd, isReentrant);
|
|
1067
1138
|
sdkQuery.close();
|
|
1068
1139
|
});
|
|
1069
1140
|
|
|
@@ -1106,6 +1177,92 @@ function readCredentialFile(path: string): string | undefined {
|
|
|
1106
1177
|
}
|
|
1107
1178
|
}
|
|
1108
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, {});
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
const servers = connectorMcpServers(inventory);
|
|
1229
|
+
debug(`connectors: declaring ${Object.keys(servers).length} of ${inventory.connectors.length} installed`,
|
|
1230
|
+
Object.keys(servers).join(", ") || "none");
|
|
1231
|
+
connectorServerCache.set(key, servers);
|
|
1232
|
+
// Persist so the NEXT cold process has this synchronously. Priming always
|
|
1233
|
+
// loses the race against turn 1 in its own process; a cache written by an
|
|
1234
|
+
// earlier run is the only thing turn 1 can read in time (vstack#870).
|
|
1235
|
+
if (writeCachedConnectors(inventory.connectors, key)) {
|
|
1236
|
+
debug(`connectors: cached ${inventory.connectors.length} entries`);
|
|
1237
|
+
}
|
|
1238
|
+
} catch (error) {
|
|
1239
|
+
debug("connectors: declaration lookup threw; declaring none", error);
|
|
1240
|
+
connectorServerCache.set(key, {});
|
|
1241
|
+
} finally {
|
|
1242
|
+
connectorServerPending.delete(key);
|
|
1243
|
+
}
|
|
1244
|
+
})();
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/** Synchronous snapshot for the query path; `{}` until priming resolves. */
|
|
1248
|
+
function connectorServersSnapshot(): Record<string, unknown> {
|
|
1249
|
+
const key = connectorScopeKey();
|
|
1250
|
+
const ready = connectorServerCache.get(key);
|
|
1251
|
+
if (ready) return ready;
|
|
1252
|
+
// Always start (or continue) the live fetch — the cache is a head start, not
|
|
1253
|
+
// a replacement, and the refresh keeps the next process current.
|
|
1254
|
+
primeConnectorServers();
|
|
1255
|
+
// Fall back to the previous run's inventory, read synchronously. This is the
|
|
1256
|
+
// only thing that can populate turn 1 of a cold process, because priming
|
|
1257
|
+
// cannot finish before the first query is built (vstack#870).
|
|
1258
|
+
const cached = readCachedConnectors(key);
|
|
1259
|
+
if (!cached) return {};
|
|
1260
|
+
const servers = connectorMcpServers({ ok: true, complete: true, connectors: cached });
|
|
1261
|
+
if (Object.keys(servers).length === 0) return {};
|
|
1262
|
+
debug(`connectors: turn-1 declarations from cache — ${Object.keys(servers).join(", ")}`);
|
|
1263
|
+
return servers;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1109
1266
|
// Deterministic connector enumeration for the host app (vstack#838). Reports the
|
|
1110
1267
|
// failure reason rather than an empty list, so "no connectors" and "could not
|
|
1111
1268
|
// check" stay distinguishable.
|
|
@@ -1239,10 +1396,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
1239
1396
|
|
|
1240
1397
|
// --- Provider ---
|
|
1241
1398
|
//
|
|
1242
|
-
//
|
|
1243
|
-
//
|
|
1244
|
-
//
|
|
1245
|
-
//
|
|
1399
|
+
// Native registration (pi >=0.81): register unconditionally; the provider's
|
|
1400
|
+
// own auth check/resolve report whether Claude credentials exist, so pi
|
|
1401
|
+
// hides claude-bridge models while no account is connected and shows them
|
|
1402
|
+
// when one appears. session_start and pre-spawn re-upsert the provider to
|
|
1403
|
+
// force pi's availability recompute at those boundaries.
|
|
1246
1404
|
//
|
|
1247
1405
|
// applyProviderRegistration also claims the primary-instance token (first
|
|
1248
1406
|
// load wins) and enforces the multi-instance guard: a non-primary subagent
|