@vanillagreen/pi-claude-bridge 1.6.2 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,14 +1,13 @@
1
1
  import { calculateCost, type AssistantMessage, type AssistantMessageEventStream, type Context, type Model, type SimpleStreamOptions, type Tool } from "@earendil-works/pi-ai";
2
2
  import * as piAi from "@earendil-works/pi-ai";
3
3
  import { type ExtensionAPI, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
4
- import { createSdkMcpServer, query, type EffortLevel, type SDKMessage, type SDKUserMessage, type SettingSource, type SpawnOptions, type SpawnedProcess } from "@anthropic-ai/claude-agent-sdk";
4
+ import { createSdkMcpServer, query, type EffortLevel, type HookCallback, type SDKMessage, type SDKUserMessage, type SettingSource, type SpawnOptions, type SpawnedProcess } from "@anthropic-ai/claude-agent-sdk";
5
5
  import type { Base64ImageSource, ContentBlockParam, MessageParam } from "@anthropic-ai/sdk/resources";
6
6
  import { createSession, deleteSession, openSession, repairToolPairing } from "cc-session-io";
7
7
  import { spawn as spawnProcess } from "child_process";
8
8
  import { createHash } from "crypto";
9
9
  import { accessSync, appendFileSync, chmodSync, constants as fsConstants, mkdirSync, readFileSync, realpathSync, statSync } from "fs";
10
10
  import { resolve as pathResolve } from "path";
11
- import { homedir } from "os";
12
11
  import { delimiter, dirname, join } from "path";
13
12
  import { PROVIDER_ID, messageContentToText, convertPiMessages } from "./convert.js";
14
13
  import { FABLE_FALLBACK_MODEL_ID, FABLE_MODEL_ID, buildModels, fallbackModelForPrimaryModel } from "./models.js";
@@ -17,7 +16,8 @@ import { verifyWrittenSession as _verifyWrittenSession } from "./session-verify.
17
16
  import { extractAllToolResults as _extractAllToolResults, type McpResult } from "./extract-tool-results.js";
18
17
  import { QueryContext, ctx, stackDepth, pushContext, popContext } from "./query-state.js";
19
18
  import { findUnpairedToolUses, summarizeMissingToolNames, type MissingToolResult } from "./tool-pairing-audit.js";
20
- import { loadConfig, normalizeEffortLevel, recordProjectTrust, type Config } from "./config.js";
19
+ import { isolatedFromEnv, loadConfig, normalizeConnectorWriteMode, normalizeEffortLevel, piUserDir, recordProjectTrust, type Config, type ConnectorWriteMode } from "./config.js";
20
+ import { decideRegistration, hasClaudeCredentials } from "./auth-presence.js";
21
21
  import { extractAgentsAppend } from "./agents-md.js";
22
22
  import { buildPromptContextAppend } from "./prompt-context.js";
23
23
  import { jsonSchemaToZodShape } from "./typebox-to-zod.js";
@@ -32,14 +32,14 @@ const newAssistantMessageEventStream: () => AssistantMessageEventStream =
32
32
  : () => new _piAi.AssistantMessageEventStream();
33
33
 
34
34
  // --- Debug logging ---
35
- // CLAUDE_BRIDGE_DEBUG=1 enables debug logging to ~/.pi/agent/claude-bridge.log
35
+ // CLAUDE_BRIDGE_DEBUG=1 enables debug logging to <piUserDir>/claude-bridge.log
36
+ // (~/.pi/agent/claude-bridge.log unless PI_CODING_AGENT_DIR points elsewhere).
36
37
 
37
38
  const DEBUG = process.env.CLAUDE_BRIDGE_DEBUG === "1";
38
- const DEBUG_LOG_PATH = process.env.CLAUDE_BRIDGE_DEBUG_PATH || join(homedir(), ".pi", "agent", "claude-bridge.log");
39
- const DEFAULT_DIAG_LOG_PATH = join(homedir(), ".pi", "agent", "claude-bridge-diag.log");
39
+ const DEBUG_LOG_PATH = process.env.CLAUDE_BRIDGE_DEBUG_PATH || join(piUserDir(), "claude-bridge.log");
40
40
 
41
41
  function diagLogPath(): string {
42
- return process.env.CLAUDE_BRIDGE_DIAG_PATH || DEFAULT_DIAG_LOG_PATH;
42
+ return process.env.CLAUDE_BRIDGE_DIAG_PATH || join(piUserDir(), "claude-bridge-diag.log");
43
43
  }
44
44
 
45
45
  // Ensure log directories exist when debug is enabled
@@ -81,9 +81,13 @@ function executableFromPath(name: string): string | undefined {
81
81
  return undefined;
82
82
  }
83
83
 
84
- function resolveClaudeExecutable(configured?: string): string | undefined {
84
+ export function resolveClaudeExecutable(configured?: string): string | undefined {
85
85
  const trimmed = configured?.trim();
86
86
  if (trimmed) return trimmed;
87
+ // Isolated mode: never run whatever `claude` happens to be on $PATH — the
88
+ // host app either pins an executable in config or gets the SDK's bundled
89
+ // default, which ships inside the host bundle.
90
+ if (isolatedFromEnv()) return undefined;
87
91
  return executableFromPath("claude") ?? executableFromPath("claude-code");
88
92
  }
89
93
 
@@ -456,20 +460,31 @@ export function __testGetBridgeIntegrityState(): { sharedSession: SessionState |
456
460
 
457
461
  // --- Constants ---
458
462
 
459
- // Global key to prevent re-registration of the provider across module reloads.
463
+ // Two process-global tokens govern provider registration across module reloads.
464
+ // Extensions like pi-subagents spawn a subagent that loads THIS module again as
465
+ // a fresh (non-primary) instance. Two failure modes must be prevented:
466
+ // (1) a subagent's registerProvider() overwriting the parent's `streamSimple`
467
+ // in the shared ModelRegistry — the parent would then deliver tool results
468
+ // through the subagent's empty-state streamSimple and break tool pairing;
469
+ // (2) a subagent STEALING registration ownership: if the parent loaded
470
+ // uncredentialed and the user logged in mid-session, a later subagent load
471
+ // would see credentialed + no-owner and claim ownership + register ITS
472
+ // streamSimple, split-braining the shared session/ctx.
460
473
  //
461
- // Extensions like pi-subagents spawn a subagent and it loads this module
462
- // again. Without this guard, the subagent's call to registerProvider() would
463
- // overwrite the parent's `streamSimple` function reference in the shared
464
- // ModelRegistry. When the parent later delivers a tool result, it would call
465
- // the subagent's `streamSimple` (which has empty state) instead of its own.
474
+ // PRIMARY_INSTANCE_KEY claimed UNCONDITIONALLY (regardless of credentials) by
475
+ // the first-loaded module instance. ONLY the primary instance may ever
476
+ // register, unregister, or claim the stream guard. Non-primary instances
477
+ // (subagents) always no-op. This is the authority token; it closes (2).
466
478
  //
467
- // By storing the active streamSimple in a Symbol.for() global (shared across all
468
- // module instances), we ensure only the FIRST instance to register takes effect.
469
- // Subsequent instances wrap the stored function instead of overwriting it.
479
+ // ACTIVE_STREAM_SIMPLE_KEY holds the registered instance's `streamSimple`.
480
+ // Only the primary claims it, and only while a registration is live. It doubles
481
+ // as the "already registered" flag (guard === our streamSimple) and the routing
482
+ // target for reentrant subagent calls; it closes (1).
470
483
  //
471
- // On session_shutdown (including /reload), clearSession() resets this so a fresh
472
- // registration can occur for the next session.
484
+ // Both are released on session_shutdown (incl. /reload) by releaseProviderTokens
485
+ // so the next module load starts clean. See applyProviderRegistration for the
486
+ // state machine and auth-presence.ts/decideRegistration for the pure decision.
487
+ const PRIMARY_INSTANCE_KEY = Symbol.for("claude-bridge:primaryInstance");
473
488
  const ACTIVE_STREAM_SIMPLE_KEY = Symbol.for("claude-bridge:activeStreamSimple");
474
489
  const COMMANDS_REGISTERED_KEY = Symbol.for("claude-bridge:commandsRegistered");
475
490
 
@@ -505,6 +520,207 @@ export const CLAUDE_BRIDGE_TOOL_ISOLATION = {
505
520
  allowedTools: [`mcp__${MCP_SERVER_NAME}__*`],
506
521
  } satisfies Pick<NonNullable<Parameters<typeof query>[0]["options"]>, "tools" | "allowedTools" | "disallowedTools">;
507
522
 
523
+ // --- Claude account cloud MCP connectors (Gmail / Calendar / Drive) ---
524
+ //
525
+ // By default the bridge suppresses claude.ai cloud MCP servers (see the
526
+ // ENABLE_CLAUDEAI_MCP_SERVERS="0" note near the query builder) so Pi owns tool
527
+ // execution and tokens stay lean. This opt-in flag lets the authenticated
528
+ // Claude account's authorized Google connectors flow through to the model,
529
+ // exposing Gmail/Calendar/Drive tools the account has connected. Gated so the
530
+ // default behavior is unchanged. See
531
+ // docs/plans/claude-bridge-google-connectors.md.
532
+ export function connectorsEnabledFromEnv(): boolean {
533
+ const v = (process.env.CLAUDE_BRIDGE_ENABLE_CONNECTORS ?? "").trim().toLowerCase();
534
+ return v === "1" || v === "true" || v === "yes" || v === "on";
535
+ }
536
+
537
+ // Connectors are enabled if EITHER the env var is truthy OR the resolved bridge
538
+ // config sets `provider.enableConnectors`. Env is the simplest per-process knob
539
+ // (one sidecar per Claude account sets it in its child env); config lets a host
540
+ // app enable it declaratively via its written settings.json.
541
+ export function connectorsEnabledFor(config?: Config): boolean {
542
+ return connectorsEnabledFromEnv() || config?.provider?.enableConnectors === true;
543
+ }
544
+
545
+ // Cloud MCP connector tool namespaces auto-allowed when connectors are enabled.
546
+ // Names match Claude Code's claude.ai connector servers.
547
+ export const CLAUDE_AI_CONNECTOR_TOOL_PATTERNS = [
548
+ "mcp__claude_ai_Gmail__*",
549
+ "mcp__claude_ai_Google_Calendar__*",
550
+ "mcp__claude_ai_Google_Drive__*",
551
+ ];
552
+
553
+ // Claude Code registers a Claude account's cloud connectors as DEFERRED tools
554
+ // that the model must load via ToolSearch (and enumerate via the MCP-resource
555
+ // tools). The default bridge isolation disallows all three so Pi owns tool
556
+ // discovery — but that hides the connectors from the model entirely. When
557
+ // connectors are enabled we must let these through so Gmail/Calendar/Drive are
558
+ // discoverable. Verified: disallowing ToolSearch reliably yields NO_CONNECTORS.
559
+ export const CONNECTOR_DISCOVERY_TOOLS = ["ToolSearch", "ListMcpResources", "ReadMcpResource"];
560
+
561
+ // --- Connector WRITE tool control (read-inline / write-by-approval) ---
562
+ //
563
+ // Connector tools execute INSIDE claude via the bridge, so Memsira's Pi-level
564
+ // ConsentGate never sees them. To keep every connector WRITE explicit + gated,
565
+ // connector chat sessions run read-only (writes denied); the model performs a
566
+ // write only through a gated Pi custom-tool whose app-side dispatcher runs a
567
+ // ONE-SHOT write-enabled bridge query. This block is the bridge lever for that:
568
+ // deny connector write tools by default, allow them only for that executor.
569
+ //
570
+ // Cloud connector server namespaces (the `mcp__<server>__` prefix).
571
+ const CONNECTOR_NS_GMAIL = "mcp__claude_ai_Gmail__";
572
+ const CONNECTOR_NS_CALENDAR = "mcp__claude_ai_Google_Calendar__";
573
+ const CONNECTOR_NS_DRIVE = "mcp__claude_ai_Google_Drive__";
574
+ const CONNECTOR_NAMESPACES = [CONNECTOR_NS_GMAIL, CONNECTOR_NS_CALENDAR, CONNECTOR_NS_DRIVE];
575
+
576
+ // Read-verb prefixes: a connector tool whose name (the segment after its
577
+ // namespace) starts with one of these is a non-mutating READ and always stays
578
+ // available. Everything else on a connector namespace is treated as a WRITE.
579
+ // Observed reads (POC): list_labels, search_threads, get_message, list_calendars,
580
+ // list_events, get_event — i.e. list_/search_/get_; the rest are common Google
581
+ // read verbs. Keep this list tight: mis-classifying a read as a write only
582
+ // blocks a read (safe, easily fixed), whereas mis-classifying a write as a read
583
+ // would open an ungated mutation.
584
+ const CONNECTOR_READ_PREFIXES = [
585
+ "list_", "search_", "get_", "read_", "fetch_", "find_",
586
+ "download_", "describe_", "query_", "count_", "view_",
587
+ ];
588
+
589
+ // Explicit known write tool names (current claude.ai connectors). Passed to the
590
+ // SDK disallowedTools so today's writes are removed from the model's context by
591
+ // exact tool id (the CLI matcher only supports exact ids or a whole-server glob).
592
+ export const CONNECTOR_WRITE_TOOLS = [
593
+ `${CONNECTOR_NS_GMAIL}create_draft`,
594
+ `${CONNECTOR_NS_GMAIL}create_label`,
595
+ `${CONNECTOR_NS_GMAIL}label_message`,
596
+ `${CONNECTOR_NS_GMAIL}label_thread`,
597
+ `${CONNECTOR_NS_GMAIL}unlabel_message`,
598
+ `${CONNECTOR_NS_GMAIL}unlabel_thread`,
599
+ `${CONNECTOR_NS_GMAIL}apply_sensitive_label`,
600
+ `${CONNECTOR_NS_GMAIL}remove_sensitive_label`,
601
+ `${CONNECTOR_NS_CALENDAR}create_event`,
602
+ `${CONNECTOR_NS_CALENDAR}update_event`,
603
+ `${CONNECTOR_NS_CALENDAR}delete_event`,
604
+ `${CONNECTOR_NS_CALENDAR}respond_to_event`,
605
+ `${CONNECTOR_NS_DRIVE}create_file`,
606
+ `${CONNECTOR_NS_DRIVE}copy_file`,
607
+ ];
608
+
609
+ // Classify a connector tool name as a WRITE (mutating) tool. FAIL CLOSED: a tool
610
+ // on a connector namespace is a write UNLESS its verb is a known read prefix, so
611
+ // not-yet-known future write tools (e.g. Gmail send_message, Drive delete_file,
612
+ // Calendar add_attendee) are classified as writes and blocked in a read-only
613
+ // session. Non-connector tools (Pi custom-tools, ToolSearch, MCP-resource tools)
614
+ // are never connector writes → false. Used by connectorWriteDenyHook and by
615
+ // callers (e.g. the one-shot write executor) that enumerate live connector tools.
616
+ export function isConnectorWriteTool(name: string): boolean {
617
+ const ns = CONNECTOR_NAMESPACES.find((n) => name.startsWith(n));
618
+ if (!ns) return false;
619
+ const tool = name.slice(ns.length);
620
+ return !CONNECTOR_READ_PREFIXES.some((prefix) => tool.startsWith(prefix));
621
+ }
622
+
623
+ // Connector write mode from the env override. `allow` exposes connector write
624
+ // tools; `deny` hides them. Returns undefined when unset so config can decide.
625
+ export function connectorWriteModeFromEnv(): ConnectorWriteMode | undefined {
626
+ const v = (process.env.CLAUDE_BRIDGE_CONNECTOR_WRITE ?? "").trim().toLowerCase();
627
+ if (v === "allow") return "allow";
628
+ if (v === "deny") return "deny";
629
+ return undefined;
630
+ }
631
+
632
+ // Resolve the connector write mode: env wins over config, default `deny`
633
+ // (mirrors connectorsEnabledFor's env-first precedence). Only meaningful when
634
+ // connectors are enabled; connector chat sessions keep the default deny and the
635
+ // one-shot approved-write executor sets allow (env or config).
636
+ //
637
+ // FAIL CLOSED: writes are enabled ONLY by an explicit, validated `allow`. The
638
+ // config value is re-normalized here (defense in depth over normalizeProviderConfig)
639
+ // so a raw legacy-config value like "Deny"/"read-only"/true can never be treated
640
+ // as a truthy non-deny and silently open writes — anything but exact allow → deny.
641
+ export function connectorWriteModeFor(config?: Config): ConnectorWriteMode {
642
+ const resolved = connectorWriteModeFromEnv() ?? normalizeConnectorWriteMode(config?.provider?.connectorWriteMode);
643
+ return resolved === "allow" ? "allow" : "deny";
644
+ }
645
+
646
+ // PreToolUse hook that hard-blocks connector WRITE tools at call time. Hooks run
647
+ // regardless of permissionMode (we use bypassPermissions), so this — not the
648
+ // static deny lists — is the real prefix-based runtime enforcement of
649
+ // isConnectorWriteTool. disallowedTools removes today's KNOWN writes from model
650
+ // context, but the CLI matcher can't glob the tool segment, so a future write
651
+ // tool (e.g. mcp__claude_ai_Gmail__send_message, ..._Drive__delete_file) would
652
+ // otherwise be callable in a read-only session; this hook denies it by prefix.
653
+ export function connectorWriteDenyHook(): HookCallback {
654
+ return async (input) => {
655
+ // The CLI treats a hook error/timeout as an EMPTY hook output and lets
656
+ // the tool call proceed (fail OPEN) — so any exception in this body
657
+ // must convert to a deny, never an allow. Today's body is pure string
658
+ // checks on schema-validated input; the catch pins that invariant for
659
+ // whatever gets added here later.
660
+ try {
661
+ if (input.hook_event_name !== "PreToolUse") return { continue: true };
662
+ if (!isConnectorWriteTool(input.tool_name)) return { continue: true };
663
+ return connectorWriteDenyOutput(String(input.tool_name));
664
+ } catch {
665
+ const toolName = typeof (input as { tool_name?: unknown })?.tool_name === "string"
666
+ ? (input as { tool_name: string }).tool_name
667
+ : "<unknown>";
668
+ return connectorWriteDenyOutput(toolName);
669
+ }
670
+ };
671
+ }
672
+
673
+ function connectorWriteDenyOutput(toolName: string) {
674
+ return {
675
+ hookSpecificOutput: {
676
+ hookEventName: "PreToolUse" as const,
677
+ permissionDecision: "deny" as const,
678
+ permissionDecisionReason:
679
+ `Connector write tool "${toolName}" is blocked in read-only connector mode. ` +
680
+ `Connector writes must go through Memsira's gated approval flow.`,
681
+ },
682
+ };
683
+ }
684
+
685
+ // Connector query-option fragment: tool isolation (allow/deny lists) plus, when
686
+ // connectors are enabled and writes are denied, the runtime PreToolUse write
687
+ // hook. Spread into the SDK query options; continuation queries inherit it via
688
+ // `{ ...queryOptions }`. Exported so the wiring is unit-testable end to end.
689
+ export function connectorQueryOptions(connectorsEnabled: boolean, writeMode: ConnectorWriteMode = "deny"): Partial<Pick<NonNullable<Parameters<typeof query>[0]["options"]>, "tools" | "allowedTools" | "disallowedTools" | "hooks">> {
690
+ const isolation = toolIsolationForQuery(connectorsEnabled, writeMode);
691
+ // Only enforce (and only meaningful) when connectors are on and writes denied.
692
+ if (!connectorsEnabled || writeMode === "allow") return isolation;
693
+ return { ...isolation, hooks: { PreToolUse: [{ hooks: [connectorWriteDenyHook()] }] } };
694
+ }
695
+
696
+ // Tool isolation for a query. When connectors are enabled we still remove
697
+ // Claude Code's filesystem/shell built-ins (via disallowedTools; Pi owns those)
698
+ // and auto-allow the cloud connector tool namespaces so the model can call
699
+ // Gmail/Calendar/Drive.
700
+ //
701
+ // Critically, we must OMIT `tools: []` in the connector path: an empty --tools
702
+ // allowlist strips the claude.ai cloud MCP connector tools from the model's
703
+ // view (verified — Pi's SDK-injected custom-tools survive it, but connectors do
704
+ // not). Dropping `tools` leaves the connectors visible; disallowedTools still
705
+ // hard-denies the built-ins so Pi keeps ownership of file/shell/web tools.
706
+ export function toolIsolationForQuery(connectorsEnabled: boolean, writeMode: ConnectorWriteMode = "deny"): Partial<Pick<NonNullable<Parameters<typeof query>[0]["options"]>, "tools" | "allowedTools" | "disallowedTools">> {
707
+ if (!connectorsEnabled) return CLAUDE_BRIDGE_TOOL_ISOLATION;
708
+ // Keep ToolSearch + MCP-resource tools available so the model can discover the
709
+ // deferred cloud connector tools; still block file/shell/web built-ins.
710
+ const disallowedTools = DISALLOWED_BUILTIN_TOOLS.filter((t) => !CONNECTOR_DISCOVERY_TOOLS.includes(t));
711
+ // Deny connector WRITE tools unless writes are explicitly allowed (fail
712
+ // closed: any mode but exact "allow" is treated as read-only). This removes
713
+ // today's KNOWN writes from the model's context by exact id; deny rules take
714
+ // precedence over the CLAUDE_AI_CONNECTOR_TOOL_PATTERNS allow rules below, so
715
+ // reads stay available. Runtime enforcement covering unknown/future write
716
+ // tools is done by connectorWriteDenyHook — see connectorQueryOptions.
717
+ if (writeMode !== "allow") disallowedTools.push(...CONNECTOR_WRITE_TOOLS);
718
+ return {
719
+ disallowedTools,
720
+ allowedTools: [...CLAUDE_BRIDGE_TOOL_ISOLATION.allowedTools, ...CLAUDE_AI_CONNECTOR_TOOL_PATTERNS],
721
+ };
722
+ }
723
+
508
724
  // --- Session persistence ---
509
725
 
510
726
  interface SessionState {
@@ -1261,6 +1477,55 @@ function resolveMcpTools(context: Context, excludeToolName?: string): {
1261
1477
  return { mcpTools, customToolNameToSdk, customToolNameToPi };
1262
1478
  }
1263
1479
 
1480
+ /** Finalizes the current pi turn when the SDK invokes an MCP tool handler
1481
+ * before emitting `message_stop` or the completed assistant message.
1482
+ *
1483
+ * Observed with Claude Code under pi 0.80's steer draining (tool result and
1484
+ * drained steer arrive in one provider call): the NEXT tool turn's tool_use
1485
+ * streams in, the SDK invokes the MCP handler — and neither terminal event
1486
+ * ever arrives. The invocation itself proves the assistant turn is committed,
1487
+ * so end the pi stream here exactly like the `message_stop` path; otherwise
1488
+ * the handler blocks on a result pi will never deliver (deadlock). No-op when
1489
+ * the turn already ended (stream null) or the tool call isn't part of the
1490
+ * currently streamed turn. */
1491
+ function finalizeToolUseTurnFromMcpInvocation(
1492
+ queryCtx: QueryContext,
1493
+ toolCallId: string,
1494
+ toolName: string,
1495
+ mappedArgs: Record<string, unknown>,
1496
+ ): void {
1497
+ if (!queryCtx.currentPiStream || !queryCtx.turnOutput) return;
1498
+ let idx = queryCtx.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === toolCallId);
1499
+ if (idx >= 0) {
1500
+ const block = queryCtx.turnBlocks[idx] as any;
1501
+ if ("partialJson" in block) {
1502
+ // Stream ended before content_block_stop — settle the args from the
1503
+ // partial JSON the same way content_block_stop would have.
1504
+ block.arguments = mapToolArgs(block.name, parsePartialJson(block.partialJson, block.arguments));
1505
+ queryCtx.updateToolCallArgs(block.id, block.arguments);
1506
+ delete block.partialJson;
1507
+ delete block.index;
1508
+ queryCtx.currentPiStream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: queryCtx.turnOutput });
1509
+ }
1510
+ } else {
1511
+ // The invocation can arrive before the tool_use is streamed at all
1512
+ // (observed after a tool-result+steer provider call reset the turn):
1513
+ // synthesize the toolCall from the claim — the MCP call carries the
1514
+ // authoritative id, name, and arguments.
1515
+ queryCtx.turnBlocks.push({ type: "toolCall", id: toolCallId, name: toolName, arguments: mappedArgs });
1516
+ idx = queryCtx.turnBlocks.length - 1;
1517
+ const block = queryCtx.turnBlocks[idx] as any;
1518
+ queryCtx.currentPiStream.push({ type: "toolcall_start", contentIndex: idx, partial: queryCtx.turnOutput });
1519
+ queryCtx.currentPiStream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: queryCtx.turnOutput });
1520
+ }
1521
+ queryCtx.turnSawToolCall = true;
1522
+ queryCtx.turnOutput.stopReason = "toolUse";
1523
+ debug(`mcp handler: finalizing tool_use turn from MCP invocation [${toolCallId}] (${toolName}) — SDK invoked the tool before message_stop/assistant message`);
1524
+ queryCtx.currentPiStream.push({ type: "done", reason: "toolUse", message: queryCtx.turnOutput });
1525
+ queryCtx.currentPiStream.end();
1526
+ queryCtx.currentPiStream = null;
1527
+ }
1528
+
1264
1529
  // Creates an MCP server that bridges pi tools to the SDK. Each tool handler
1265
1530
  // blocks on a Promise until pi delivers the tool result via streamSimple.
1266
1531
  // Handlers claim their tool_call id by matching the actual MCP call
@@ -1299,6 +1564,7 @@ function buildMcpServers(tools: Tool[], queryCtx: QueryContext): Record<string,
1299
1564
  return result;
1300
1565
  }
1301
1566
  debug(`mcp handler: ${tool.name} [${toolCallId}] → waiting`);
1567
+ finalizeToolUseTurnFromMcpInvocation(queryCtx, toolCallId, tool.name, mappedArgs);
1302
1568
  return new Promise<McpResult>((resolve) => {
1303
1569
  queryCtx.pendingToolCalls.set(toolCallId, {
1304
1570
  toolName: tool.name,
@@ -1762,6 +2028,92 @@ async function consumeQuery(
1762
2028
  return { capturedSessionId };
1763
2029
  }
1764
2030
 
2031
+ // Claim the primary-instance token for this module instance if unclaimed, and
2032
+ // report whether this instance is the primary. First-loaded instance wins,
2033
+ // UNCONDITIONALLY (before any credential check), so a later subagent load can
2034
+ // never become primary and steal registration ownership.
2035
+ function claimPrimaryInstance(): boolean {
2036
+ const g = globalThis as Record<symbol, any>;
2037
+ if (!g[PRIMARY_INSTANCE_KEY]) g[PRIMARY_INSTANCE_KEY] = streamClaudeAgentSdk;
2038
+ return g[PRIMARY_INSTANCE_KEY] === streamClaudeAgentSdk;
2039
+ }
2040
+
2041
+ // Release both process-global tokens this instance owns. Called on
2042
+ // session_shutdown (incl. /reload) so the freshly loaded instance starts clean.
2043
+ // NOTE: this does NOT unregister the provider — the ModelRegistry's
2044
+ // registeredProviders is a process-lifetime Map that survives module reload, so
2045
+ // retraction on logout is handled by applyProviderRegistration's defensive
2046
+ // unregister on the next load/session_start, not here.
2047
+ function releaseProviderTokens(event: string): void {
2048
+ const g = globalThis as Record<symbol, any>;
2049
+ if (g[ACTIVE_STREAM_SIMPLE_KEY] === streamClaudeAgentSdk) {
2050
+ debug(`${event}: clearing ACTIVE_STREAM_SIMPLE_KEY`);
2051
+ g[ACTIVE_STREAM_SIMPLE_KEY] = undefined;
2052
+ }
2053
+ if (g[PRIMARY_INSTANCE_KEY] === streamClaudeAgentSdk) {
2054
+ debug(`${event}: clearing PRIMARY_INSTANCE_KEY`);
2055
+ g[PRIMARY_INSTANCE_KEY] = undefined;
2056
+ }
2057
+ }
2058
+
2059
+ // Conditional (un)registration driven by real credential presence + instance
2060
+ // primacy. Run at extension load, on every session_start, and at pre-spawn
2061
+ // (fail-fast) so a `claude login` / logout is reflected without a /reload.
2062
+ //
2063
+ // decideRegistration encodes the pure state machine; this wrapper performs the
2064
+ // matching token mutations so tokens and registration never diverge:
2065
+ // - register: claim the stream guard, THEN registerProvider. If register
2066
+ // throws/queue-fails, release the stream guard (but keep primacy) so a
2067
+ // later re-check can retry cleanly (self-healing); errors are swallowed so
2068
+ // a session_start handler can't crash the dispatch.
2069
+ // - unregister: pi.unregisterProvider (idempotent), THEN release the stream
2070
+ // guard if we own it. Defensive even when we never registered — this is the
2071
+ // only retraction path for a stale registration surviving /reload. At LOAD
2072
+ // the SDK's unregister only filters the pending-registration queue and can't
2073
+ // mutate the persistent registry (loader.js), so the effective retraction
2074
+ // lands on the post-load session_start re-check; the load-time call is a
2075
+ // harmless idempotent no-op that also cancels any same-pass queued register.
2076
+ // Non-primary instances (subagents) always decide noop and touch nothing.
2077
+ function applyProviderRegistration(trigger: string): void {
2078
+ const pi = extensionApi;
2079
+ if (!pi) { debug(`${trigger}: applyProviderRegistration skipped — no extensionApi`); return; }
2080
+ const g = globalThis as Record<symbol, any>;
2081
+ const isPrimary = claimPrimaryInstance();
2082
+ const credentialed = hasClaudeCredentials();
2083
+ const registered = g[ACTIVE_STREAM_SIMPLE_KEY] === streamClaudeAgentSdk;
2084
+ const decision = decideRegistration({ credentialed, isPrimary, registered });
2085
+ debug(`${trigger}: registration decision=${decision} credentialed=${credentialed} isPrimary=${isPrimary} registered=${registered} (module=${moduleInstanceId})`);
2086
+ if (decision === "register") {
2087
+ // Claim ordering: stream guard BEFORE registerProvider so a concurrent
2088
+ // subagent can never observe a registered provider without an owner.
2089
+ g[ACTIVE_STREAM_SIMPLE_KEY] = streamClaudeAgentSdk;
2090
+ try {
2091
+ pi.registerProvider(PROVIDER_ID, {
2092
+ baseUrl: "claude-bridge",
2093
+ apiKey: "not-used",
2094
+ api: "claude-bridge",
2095
+ models: MODELS,
2096
+ // Cast: pi-ai AssistantMessageEventStream diamond dep between pi-coding-agent and pi-agent-core
2097
+ streamSimple: streamClaudeAgentSdk as any,
2098
+ });
2099
+ } catch (err) {
2100
+ // Self-heal: release ONLY the stream guard we just claimed so a later
2101
+ // re-check (primary + credentialed + not-registered → register) retries.
2102
+ // Keep PRIMARY_INSTANCE_KEY: releasing it would reopen the subagent
2103
+ // ownership-steal window, and retry does not need it released.
2104
+ if (g[ACTIVE_STREAM_SIMPLE_KEY] === streamClaudeAgentSdk) g[ACTIVE_STREAM_SIMPLE_KEY] = undefined;
2105
+ debug(`${trigger}: registerProvider threw; released stream guard for retry (kept primary):`, err);
2106
+ }
2107
+ } else if (decision === "unregister") {
2108
+ try {
2109
+ pi.unregisterProvider(PROVIDER_ID);
2110
+ } catch (err) {
2111
+ debug(`${trigger}: unregisterProvider threw (ignored):`, err);
2112
+ }
2113
+ if (g[ACTIVE_STREAM_SIMPLE_KEY] === streamClaudeAgentSdk) g[ACTIVE_STREAM_SIMPLE_KEY] = undefined;
2114
+ }
2115
+ }
2116
+
1765
2117
  /** Provider entry point. Pi calls this for each new prompt and each tool result.
1766
2118
  * Two cases: tool result delivery (active query) or fresh query. */
1767
2119
  function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
@@ -1861,6 +2213,34 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1861
2213
 
1862
2214
  // --- Fresh query ---
1863
2215
 
2216
+ // Fail-fast credential re-check (only for a fresh query — NEVER for
2217
+ // tool-result delivery of an in-flight query, handled above, where creds were
2218
+ // valid at start and failing mid-turn would break tool pairing). This bounds
2219
+ // the retraction-latency window from "next session boundary" to "first use":
2220
+ // if credentials vanished since the last session_start, (a) trigger the same
2221
+ // re-evaluation applyProviderRegistration does (primary-only; retracts the
2222
+ // stale registration), and (b) fail this request with a clear, actionable
2223
+ // message instead of letting the SDK spawn die with a generic error. The
2224
+ // check is cheap (existsSync + env reads only, no credential contents).
2225
+ if (!hasClaudeCredentials()) {
2226
+ try { applyProviderRegistration("pre-spawn"); } catch { /* best effort */ }
2227
+ const message = "Claude account not connected — connect an account (or run `claude login`) and retry.";
2228
+ debug(`provider: pre-spawn credential check failed; failing fast: ${message}`);
2229
+ const errorOutput: AssistantMessage = {
2230
+ role: "assistant", content: [],
2231
+ api: model.api, provider: model.provider, model: model.id,
2232
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
2233
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
2234
+ stopReason: "error", timestamp: Date.now(),
2235
+ errorMessage: message,
2236
+ };
2237
+ queueMicrotask(() => {
2238
+ stream.push({ type: "error", reason: "error", error: errorOutput });
2239
+ stream.end();
2240
+ });
2241
+ return stream;
2242
+ }
2243
+
1864
2244
  // 1. Determine reentrancy and push parent context if needed.
1865
2245
  const isReentrant = ctx().activeQuery !== null;
1866
2246
  if (isReentrant) pushContext();
@@ -1902,6 +2282,13 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1902
2282
  const mcpServers = buildMcpServers(mcpTools, ctx());
1903
2283
  const bridgeConfig = loadConfig(cwd);
1904
2284
  const providerSettings = bridgeConfig.provider ?? {};
2285
+ // Whether to expose the Claude account's claude.ai cloud MCP connectors
2286
+ // (Gmail/Calendar/Drive). Enabled via env or config; drives setting-sources,
2287
+ // tool isolation, and the ENABLE_CLAUDEAI_MCP_SERVERS child-env gate below.
2288
+ const enableCloudMcp = connectorsEnabledFor(bridgeConfig);
2289
+ // Connector WRITE control: read-only by default (writes denied); the one-shot
2290
+ // approved-write executor sets CLAUDE_BRIDGE_CONNECTOR_WRITE=allow / config.
2291
+ const connectorWriteMode = connectorWriteModeFor(bridgeConfig);
1905
2292
  const appendSystemPrompt = providerSettings.appendSystemPrompt !== false;
1906
2293
  const agentsAppend = appendSystemPrompt ? extractAgentsAppend() : undefined;
1907
2294
  const skillsAppend = appendSystemPrompt ? extractSkillsBlock(context.systemPrompt) : undefined;
@@ -1913,9 +2300,16 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1913
2300
  // SDK uses isolation mode and avoids filesystem settings. If users turn that
1914
2301
  // off, load user/project settings but pass --strict-mcp-config so Claude Code
1915
2302
  // ignores auto-discovered filesystem MCP servers while Pi owns tool execution.
1916
- const settingSources: SettingSource[] | undefined = appendSystemPrompt
1917
- ? undefined
1918
- : providerSettings.settingSources ?? ["user", "project"];
2303
+ // claude.ai cloud MCP connectors only load when Claude Code resolves its
2304
+ // filesystem setting sources. The SDK treats settingSources=undefined as
2305
+ // isolation (no sources), which drops the connectors even with
2306
+ // ENABLE_CLAUDEAI_MCP_SERVERS=1. When connectors are enabled we force the CLI
2307
+ // default source set so Gmail/Calendar/Drive surface.
2308
+ const settingSources: SettingSource[] | undefined = enableCloudMcp
2309
+ ? (providerSettings.settingSources ?? ["user", "project", "local"])
2310
+ : appendSystemPrompt
2311
+ ? undefined
2312
+ : providerSettings.settingSources ?? ["user", "project"];
1919
2313
  const strictMcpConfigEnabled = !appendSystemPrompt && providerSettings.strictMcpConfig !== false;
1920
2314
  const claudeExecutable = resolveClaudeExecutable(providerSettings.pathToClaudeCodeExecutable);
1921
2315
  const claudeExecutablePreflight = claudeExecutable ? preflightClaudeExecutable(claudeExecutable, cwd) : undefined;
@@ -1947,12 +2341,14 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1947
2341
  // also autocompact would double-flush the prompt cache and races pi's
1948
2342
  // threshold with CC's, including CC's anti-thrashing guard (issue #8).
1949
2343
  // Manual /compact in CC still works (we never invoke it).
1950
- const childEnv = { ...process.env, ENABLE_CLAUDEAI_MCP_SERVERS: "0", DISABLE_AUTO_COMPACT: "1" };
2344
+ // When connectors are enabled, allow claude.ai cloud MCP servers so the
2345
+ // authenticated account's Gmail/Calendar/Drive tools load. Default stays "0".
2346
+ const childEnv = { ...process.env, ENABLE_CLAUDEAI_MCP_SERVERS: enableCloudMcp ? "1" : "0", DISABLE_AUTO_COMPACT: "1" };
1951
2347
  const queryOptions: NonNullable<Parameters<typeof query>[0]["options"]> = {
1952
2348
  cwd,
1953
2349
  model: model.id,
1954
2350
  env: childEnv,
1955
- ...CLAUDE_BRIDGE_TOOL_ISOLATION,
2351
+ ...connectorQueryOptions(enableCloudMcp, connectorWriteMode),
1956
2352
  permissionMode: "bypassPermissions",
1957
2353
  includePartialMessages: true,
1958
2354
  ...(fallbackModel ? { fallbackModel } : {}),
@@ -1975,7 +2371,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1975
2371
  `model=${model.id} msgs=${context.messages.length} tools=${mcpTools.length}`,
1976
2372
  `resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
1977
2373
  `fallback=${fallbackModel ?? "none"}`,
1978
- `appendSys=${appendSystemPrompt} promptCtx=${promptContextAppend.labels.join(",") || "none"} strictMcp=${strictMcpConfigEnabled} fastMode=${providerSettings.fastMode === true}`,
2374
+ `appendSys=${appendSystemPrompt} promptCtx=${promptContextAppend.labels.join(",") || "none"} strictMcp=${strictMcpConfigEnabled} fastMode=${providerSettings.fastMode === true} connectors=${enableCloudMcp}`,
1979
2375
  `claudeExec=${claudeExecutablePreflight ? `${claudeExecutablePreflight.fileType}:${claudeExecutablePreflight.path}` : "sdk-default"}`,
1980
2376
  `prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`);
1981
2377
 
@@ -2259,20 +2655,15 @@ export default function (pi: ExtensionAPI) {
2259
2655
  return;
2260
2656
  }
2261
2657
 
2262
- // Reset shared session on pi session lifecycle events
2658
+ // Reset shared (Claude) conversation state on pi session lifecycle events.
2659
+ // Registration tokens are managed separately by applyProviderRegistration
2660
+ // (load / session_start / pre-spawn) and releaseProviderTokens (shutdown), so
2661
+ // a mid-session credential flip is handled while token ownership is intact.
2263
2662
  const clearSession = (event: string) => {
2264
2663
  debug(`${event}: clearing session ${sharedSession?.sessionId?.slice(0, 8) ?? "none"}`);
2265
2664
  sharedSession = null;
2266
-
2267
- // Clear the global streamSimple if this instance registered it.
2268
- // This allows /reload to work — the old instance clears the flag so
2269
- // the new instance can register fresh without wrapping stale state.
2270
- const g = globalThis as Record<symbol, any>;
2271
- if (g[ACTIVE_STREAM_SIMPLE_KEY] === streamClaudeAgentSdk) {
2272
- debug(`${event}: clearing ACTIVE_STREAM_SIMPLE_KEY`);
2273
- g[ACTIVE_STREAM_SIMPLE_KEY] = undefined;
2274
- }
2275
2665
  };
2666
+
2276
2667
  pi.on("session_start", (event, ctx) => {
2277
2668
  recordProjectTrust(ctx);
2278
2669
  piUI = ctx.ui;
@@ -2284,8 +2675,14 @@ export default function (pi: ExtensionAPI) {
2284
2675
  // them would --resume the parent's Claude jsonl and leak conversation past the
2285
2676
  // fork point. Letting the first fork turn rebuild is the correct path.
2286
2677
  if (event.reason === "startup" || event.reason === "resume") restoreSharedSessionFromPi(ctx);
2678
+ // Live availability flip: re-evaluate credential presence every
2679
+ // session_start so login/logout since load is reflected without /reload.
2680
+ applyProviderRegistration(`session_start:${event.reason}`);
2681
+ });
2682
+ pi.on("session_shutdown", () => {
2683
+ clearSession("session_shutdown");
2684
+ releaseProviderTokens("session_shutdown");
2287
2685
  });
2288
- pi.on("session_shutdown", () => clearSession("session_shutdown"));
2289
2686
  pi.on("message_end", (event, ctx) => {
2290
2687
  const message = (event as { message?: AssistantMessage }).message;
2291
2688
  if (message?.role === "assistant" && message.provider === PROVIDER_ID) schedulePersistSharedSession(ctx);
@@ -2312,30 +2709,14 @@ export default function (pi: ExtensionAPI) {
2312
2709
 
2313
2710
  // --- Provider ---
2314
2711
  //
2315
- // Guard against re-registration when the module is loaded multiple times
2316
- // (e.g., when spawning subagents). The shared ModelRegistry would otherwise
2317
- // overwrite the parent's streamSimple, breaking tool result delivery.
2318
- // See ACTIVE_STREAM_SIMPLE_KEY for the full mechanism.
2319
-
2320
- const g = globalThis as Record<symbol, any>;
2321
- if (!g[ACTIVE_STREAM_SIMPLE_KEY]) {
2322
- // First instance: store our streamSimple and register.
2323
- g[ACTIVE_STREAM_SIMPLE_KEY] = streamClaudeAgentSdk;
2324
- pi.registerProvider(PROVIDER_ID, {
2325
- baseUrl: "claude-bridge",
2326
- apiKey: "not-used",
2327
- api: "claude-bridge",
2328
- models: MODELS,
2329
- // Cast: pi-ai AssistantMessageEventStream diamond dep between pi-coding-agent and pi-agent-core
2330
- streamSimple: streamClaudeAgentSdk as any,
2331
- });
2332
- } else {
2333
- // Subsequent instance (subagent session): skip registration entirely.
2334
- // The subagent already has access to claude-bridge models via the shared
2335
- // ModelRegistry from the parent's registration. Calls to those models
2336
- // will route through the parent's streamSimple via the reentrant
2337
- // QueryContext stack mechanism.
2338
- debug(`provider: skipping re-registration, parent instance active (module=${moduleInstanceId})`);
2339
- }
2340
-
2712
+ // Register the provider ONLY when real Claude credentials are present, so
2713
+ // claude-bridge models are never advertised as available/selectable when a
2714
+ // request would fail at spawn time (pi's ModelRegistry.hasConfiguredAuth()
2715
+ // treats the dummy apiKey as "configured", so the gate must live here).
2716
+ //
2717
+ // applyProviderRegistration also claims the primary-instance token (first
2718
+ // load wins) and enforces the multi-instance guard: a non-primary subagent
2719
+ // reload always no-ops, so it never overwrites the parent's streamSimple nor
2720
+ // steals ownership. See PRIMARY_INSTANCE_KEY / ACTIVE_STREAM_SIMPLE_KEY.
2721
+ applyProviderRegistration("load");
2341
2722
  }