@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/README.md +33 -2
- package/bundle/index.js +25449 -19866
- package/package.json +5 -5
- package/src/agents-md.ts +16 -6
- package/src/auth-presence.ts +158 -0
- package/src/config.ts +75 -3
- package/src/index.ts +443 -62
- package/src/prompt-context.ts +3 -8
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
|
|
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(
|
|
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 ||
|
|
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
|
-
//
|
|
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
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
//
|
|
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
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
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
|
-
//
|
|
472
|
-
//
|
|
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
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
|
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
|
-
//
|
|
2316
|
-
//
|
|
2317
|
-
//
|
|
2318
|
-
//
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
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
|
}
|