@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/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, popContext, toolCallDrainCause } from "./query-state.js";
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 { decideRegistration, hasClaudeCredentials } from "./auth-presence.js";
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, parsePartialJson, processAssistantMessage, processStreamEvent, updateTurnOutputModel } from "./assistant-stream.js";
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 { CLAUDE_AI_CONNECTOR_TOOL_PATTERNS, CLAUDE_BRIDGE_TOOL_ISOLATION, CONNECTOR_DISCOVERY_TOOLS, CONNECTOR_WRITE_TOOLS, DISALLOWED_BUILTIN_TOOLS, connectorQueryOptions, connectorWriteDenyHook, connectorWriteModeFor, connectorWriteModeFromEnv, connectorsEnabledFor, connectorsEnabledFromEnv, isConnectorWriteTool, toolIsolationForQuery } from "./connectors.js";
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
- // state machine and auth-presence.ts/decideRegistration for the pure decision.
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(tool.name.toLowerCase(), sdkName);
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
- /** Finalizes the current pi turn when the SDK invokes an MCP tool handler
271
- * before emitting `message_stop` or the completed assistant message.
272
- *
273
- * Observed with Claude Code under pi 0.80's steer draining (tool result and
274
- * drained steer arrive in one provider call): the NEXT tool turn's tool_use
275
- * streams in, the SDK invokes the MCP handler and neither terminal event
276
- * ever arrives. The invocation itself proves the assistant turn is committed,
277
- * so end the pi stream here exactly like the `message_stop` path; otherwise
278
- * the handler blocks on a result pi will never deliver (deadlock). No-op when
279
- * the turn already ended (stream null) or the tool call isn't part of the
280
- * currently streamed turn. */
281
- function finalizeToolUseTurnFromMcpInvocation(
282
- queryCtx: QueryContext,
283
- toolCallId: string,
284
- toolName: string,
285
- mappedArgs: Record<string, unknown>,
286
- ): void {
287
- if (!queryCtx.currentPiStream || !queryCtx.turnOutput) return;
288
- let idx = queryCtx.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === toolCallId);
289
- if (idx >= 0) {
290
- const block = queryCtx.turnBlocks[idx] as any;
291
- if ("partialJson" in block) {
292
- // Stream ended before content_block_stop — settle the args from the
293
- // partial JSON the same way content_block_stop would have.
294
- block.arguments = mapToolArgs(block.name, parsePartialJson(block.partialJson, block.arguments));
295
- queryCtx.updateToolCallArgs(block.id, block.arguments);
296
- delete block.partialJson;
297
- delete block.index;
298
- queryCtx.currentPiStream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: queryCtx.turnOutput });
299
- }
300
- } else {
301
- // The invocation can arrive before the tool_use is streamed at all
302
- // (observed after a tool-result+steer provider call reset the turn):
303
- // synthesize the toolCall from the claim — the MCP call carries the
304
- // authoritative id, name, and arguments.
305
- queryCtx.turnBlocks.push({ type: "toolCall", id: toolCallId, name: toolName, arguments: mappedArgs });
306
- idx = queryCtx.turnBlocks.length - 1;
307
- const block = queryCtx.turnBlocks[idx] as any;
308
- queryCtx.currentPiStream.push({ type: "toolcall_start", contentIndex: idx, partial: queryCtx.turnOutput });
309
- queryCtx.currentPiStream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: queryCtx.turnOutput });
310
- }
311
- queryCtx.turnSawToolCall = true;
312
- queryCtx.turnOutput.stopReason = "toolUse";
313
- debug(`mcp handler: finalizing tool_use turn from MCP invocation [${toolCallId}] (${toolName}) — SDK invoked the tool before message_stop/assistant message`);
314
- queryCtx.currentPiStream.push({ type: "done", reason: "toolUse", message: queryCtx.turnOutput });
315
- queryCtx.currentPiStream.end();
316
- queryCtx.currentPiStream = null;
317
- }
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.match !== "tool-args" || claim.ambiguous) {
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
- finalizeToolUseTurnFromMcpInvocation(queryCtx, toolCallId, tool.name, mappedArgs);
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 openedExtraUsage = launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "result error");
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
- ctx().turnOutput.errorMessage = `${errors}${openedExtraUsage ? "\n\nOpened Claude Code /extra-usage helper. Complete billing/admin flow in the browser, then retry the prompt." : "\n\nRun /claude-bridge:extra, or enable Allow extra usage helper in settings."}`;
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
- break; // SDK echo of user prompt — not needed
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 = typeof info.resetsAt === "string" ? Date.parse(info.resetsAt) : undefined;
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 — the ModelRegistry's
536
- // registeredProviders is a process-lifetime Map that survives module reload, so
537
- // retraction on logout is handled by applyProviderRegistration's defensive
538
- // unregister on the next load/session_start, not here.
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
- // Conditional (un)registration driven by real credential presence + instance
552
- // primacy. Run at extension load, on every session_start, and at pre-spawn
553
- // (fail-fast) so a `claude login` / logout is reflected without a /reload.
615
+ // Native (pi >=0.81) provider registration. Run at extension load, on every
616
+ // session_start, and at pre-spawn.
554
617
  //
555
- // decideRegistration encodes the pure state machine; this wrapper performs the
556
- // matching token mutations so tokens and registration never diverge:
557
- // - register: claim the stream guard, THEN registerProvider. If register
558
- // throws/queue-fails, release the stream guard (but keep primacy) so a
559
- // later re-check can retry cleanly (self-healing); errors are swallowed so
560
- // a session_start handler can't crash the dispatch.
561
- // - unregister: pi.unregisterProvider (idempotent), THEN release the stream
562
- // guard if we own it. Defensive even when we never registered — this is the
563
- // only retraction path for a stale registration surviving /reload. At LOAD
564
- // the SDK's unregister only filters the pending-registration queue and can't
565
- // mutate the persistent registry (loader.js), so the effective retraction
566
- // lands on the post-load session_start re-check; the load-time call is a
567
- // harmless idempotent no-op that also cancels any same-pass queued register.
568
- // Non-primary instances (subagents) always decide noop and touch nothing.
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
- const credentialed = hasClaudeCredentials();
575
- const registered = g[ACTIVE_STREAM_SIMPLE_KEY] === streamClaudeAgentSdk;
576
- const decision = decideRegistration({ credentialed, isPrimary, registered });
577
- debug(`${trigger}: registration decision=${decision} credentialed=${credentialed} isPrimary=${isPrimary} registered=${registered} (module=${moduleInstanceId})`);
578
- if (decision === "register") {
579
- // Claim ordering: stream guard BEFORE registerProvider so a concurrent
580
- // subagent can never observe a registered provider without an owner.
581
- g[ACTIVE_STREAM_SIMPLE_KEY] = streamClaudeAgentSdk;
582
- try {
583
- pi.registerProvider(PROVIDER_ID, {
584
- baseUrl: "claude-bridge",
585
- apiKey: "not-used",
586
- api: "claude-bridge",
587
- models: MODELS,
588
- // Cast: pi-ai AssistantMessageEventStream diamond dep between pi-coding-agent and pi-agent-core
589
- streamSimple: streamClaudeAgentSdk as any,
590
- });
591
- } catch (err) {
592
- // Self-heal: release ONLY the stream guard we just claimed so a later
593
- // re-check (primary + credentialed + not-registered → register) retries.
594
- // Keep PRIMARY_INSTANCE_KEY: releasing it would reopen the subagent
595
- // ownership-steal window, and retry does not need it released.
596
- if (g[ACTIVE_STREAM_SIMPLE_KEY] === streamClaudeAgentSdk) g[ACTIVE_STREAM_SIMPLE_KEY] = undefined;
597
- debug(`${trigger}: registerProvider threw; released stream guard for retry (kept primary):`, err);
598
- }
599
- } else if (decision === "unregister") {
600
- try {
601
- pi.unregisterProvider(PROVIDER_ID);
602
- } catch (err) {
603
- debug(`${trigger}: unregisterProvider threw (ignored):`, err);
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 retraction-latency window from "next session boundary" to "first use":
712
- // if credentials vanished since the last session_start, (a) trigger the same
713
- // re-evaluation applyProviderRegistration does (primary-only; retracts the
714
- // stale registration), and (b) fail this request with a clear, actionable
715
- // message instead of letting the SDK spawn die with a generic error. The
716
- // check is cheap (existsSync + env reads only, no credential contents).
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 ? { 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=${ctx().turnOutput?.stopReason}, error=${ctx().turnOutput?.errorMessage}, aborted=${wasAborted}`);
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
- ctx().deferredUserMessages = [];
1048
+ abortCtx.deferredUserMessages = [];
965
1049
  debug(`provider: abort detected, marked sharedSession needsRebuild + forceRotate`);
966
- if (ctx().turnOutput) {
967
- ctx().turnOutput.stopReason = "aborted";
968
- ctx().turnOutput.errorMessage = "Operation aborted";
1050
+ if (abortCtx.turnOutput) {
1051
+ abortCtx.turnOutput.stopReason = "aborted";
1052
+ abortCtx.turnOutput.errorMessage = "Operation aborted";
969
1053
  }
970
- ctx().currentPiStream?.push({ type: "error", reason: "aborted", error: ctx().turnOutput! });
971
- ctx().currentPiStream?.end();
972
- ctx().currentPiStream = null;
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, ctx().latestCursor, sharedSession?.cursor ?? 0);
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 (ctx().deferredUserMessages.length > 0 && !isReentrant && !wasAborted) {
989
- const steerPrompt = ctx().deferredUserMessages.shift()!;
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
- ctx().resetTurnState(model);
992
- ctx().resetToolTracking();
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
- ctx().activeQuery = contQuery;
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
- ctx().activeQuery = sdkQuery;
1105
+ abortCtx.activeQuery = sdkQuery;
1022
1106
  }
1023
1107
 
1024
- finalizeCurrentStream(ctx().turnOutput?.stopReason);
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 = ctx().handledTerminalError || streamIdleTimedOut;
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
- ctx().deferredUserMessages = [];
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 (ctx().turnOutput) {
1041
- ctx().turnOutput.stopReason = options?.signal?.aborted ? "aborted" : "error";
1042
- ctx().turnOutput.errorMessage = `${error instanceof Error ? error.message : String(error)}${openedExtraUsage ? "\n\nOpened Claude Code /extra-usage helper. Complete billing/admin flow in the browser, then retry the prompt." : ""}`;
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
- ctx().currentPiStream?.push({ type: "error", reason: (ctx().turnOutput?.stopReason ?? "error") as "aborted" | "error", error: ctx().turnOutput! });
1045
- ctx().currentPiStream?.end();
1046
- ctx().currentPiStream = null;
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
- if (ctx().activeQuery === sdkQuery) {
1053
- const cause = toolCallDrainCause({ wasAborted, signalAborted: options?.signal?.aborted, streamIdleTimedOut });
1054
- reportToolResultMismatch(ctx(), "query teardown", cwd, { forceRotate: cause !== "query-end" });
1055
- // Drain pending handlers for this query as errors naming the cause —
1056
- // their results are never coming.
1057
- const drained = drainPendingToolCalls(ctx(), cause);
1058
- if (drained > 0) debug(`provider: query teardown drained ${drained} waiting MCP handler(s) as errors (cause=${cause})`);
1059
- ctx().pendingResults.clear();
1060
-
1061
- if (isReentrant) {
1062
- popContext(); // merges deferred messages and restores parent
1063
- } else {
1064
- ctx().activeQuery = null;
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
- // Register the provider ONLY when real Claude credentials are present, so
1243
- // claude-bridge models are never advertised as available/selectable when a
1244
- // request would fail at spawn time (pi's ModelRegistry.hasConfiguredAuth()
1245
- // treats the dummy apiKey as "configured", so the gate must live here).
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