@vanillagreen/pi-claude-bridge 1.9.0 → 3.2.2

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/models.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  // Canonical selection + display order for the model picker.
2
- // `resolveModelId` returns the first partial match, so `opus` resolves to the first-listed opus entry.
3
2
  // Extracted from index.ts so tests can import without activating the extension.
4
3
 
5
4
  export const FABLE_MODEL_ID = "claude-fable-5";
@@ -94,9 +93,3 @@ export function buildModels<T extends { id: string; [key: string]: any }>(piAiMo
94
93
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
95
94
  }));
96
95
  }
97
-
98
- export function resolveModelId(models: Array<{ id: string }>, input: string): string {
99
- const lower = input.toLowerCase();
100
- const match = models.find((m) => m.id === lower || m.id.includes(lower));
101
- return match ? match.id : input;
102
- }
@@ -0,0 +1,94 @@
1
+ // Native pi >=0.81 provider construction (bridge 2.x).
2
+ //
3
+ // Bridge 1.x could not register unconditionally: pi's legacy
4
+ // ModelRegistry.hasConfiguredAuth() treated the dummy `apiKey: "not-used"` as
5
+ // "configured", so the models looked connected while every request failed at
6
+ // spawn. 1.x therefore gated register/unregister on real credential presence
7
+ // (decideRegistration). The native Provider form inverts that: the provider is
8
+ // ALWAYS registered, and `auth.apiKey.check/resolve` report configured-ness
9
+ // from the same existence-only probes, so pi itself hides claude-bridge models
10
+ // while no Claude credentials are present and shows them when they appear.
11
+ //
12
+ // What the native form does NOT change (see DEVELOPMENT.md "Provider
13
+ // registration"): the process-global primary-instance/stream-guard tokens stay
14
+ // (pi's registerNativeProvider is replace-by-id, so an unguarded subagent
15
+ // re-registration would still swap in its own streamSimple), and the pre-spawn
16
+ // credential fail-fast in streamSimple stays (a mid-session logout must fail
17
+ // the turn with an actionable message even if the picker snapshot is stale).
18
+ //
19
+ // SECURITY: like auth-presence.ts, this module only reports credential
20
+ // EXISTENCE. resolve() hands pi the same dummy key the legacy config carried —
21
+ // the Claude Code subprocess does its own authentication; pi never needs a
22
+ // real secret, so none is read or exposed.
23
+
24
+ import { hasClaudeCredentials } from "./auth-presence.js";
25
+ import { PROVIDER_ID } from "./convert.js";
26
+
27
+ export const NATIVE_PROVIDER_UNSUPPORTED_MESSAGE =
28
+ "Claude bridge 2.x requires pi >= 0.81 (native provider API). Upgrade the host pi, or pin @vanillagreen/pi-claude-bridge@1.x.";
29
+
30
+ /** pi-ai gained createProvider in 0.81 alongside the object-form
31
+ * registerProvider; its presence is the capability signal for both. */
32
+ export function supportsNativeProvider(piAi: unknown): boolean {
33
+ return typeof (piAi as { createProvider?: unknown })?.createProvider === "function";
34
+ }
35
+
36
+ /** Auth source label for pi's status UI, chosen by the same existence-only
37
+ * probes hasClaudeCredentials uses. Never reads credential contents. */
38
+ export function claudeAuthSourceLabel(env: NodeJS.ProcessEnv = process.env): string {
39
+ if (env.CLAUDE_CODE_OAUTH_TOKEN?.trim()) return "CLAUDE_CODE_OAUTH_TOKEN";
40
+ if (env.ANTHROPIC_API_KEY?.trim()) return "ANTHROPIC_API_KEY";
41
+ if (env.ANTHROPIC_AUTH_TOKEN?.trim()) return "ANTHROPIC_AUTH_TOKEN";
42
+ return "Claude Code login";
43
+ }
44
+
45
+ /**
46
+ * Build the Provider object for pi.registerProvider(provider).
47
+ *
48
+ * `piAi` is the HOST's pi-ai namespace (the bundle externalizes it), passed in
49
+ * rather than imported so a pre-0.81 host fails the supportsNativeProvider()
50
+ * check with a clear message instead of crashing module load on a missing
51
+ * named export. `env` is bindable for tests; the credential probes themselves
52
+ * run at check/resolve CALL time, so a login/logout between calls is seen.
53
+ */
54
+ export function buildNativeProvider(
55
+ piAi: unknown,
56
+ models: Array<Record<string, unknown>>,
57
+ streamSimple: (...args: unknown[]) => unknown,
58
+ env: NodeJS.ProcessEnv = process.env,
59
+ // Availability probe. Defaults to direct credential presence; the extension
60
+ // passes a probe that also accepts a companion account-router pool.
61
+ hasCredentials: () => boolean = () => hasClaudeCredentials(env),
62
+ ): unknown {
63
+ if (!supportsNativeProvider(piAi)) throw new Error(NATIVE_PROVIDER_UNSUPPORTED_MESSAGE);
64
+ // The legacy config path stamped provider/api/baseUrl onto each model during
65
+ // composition; createProvider passes models through verbatim, so stamp here.
66
+ // Stamps win over any provider field the source model carries — the models
67
+ // come from pi-ai's anthropic registry and must be re-homed under pi-claude.
68
+ const stamped = models.map((model) => ({ ...model, api: "claude-bridge", baseUrl: "claude-bridge", provider: PROVIDER_ID }));
69
+ // The Claude Code subprocess router IS the implementation for both stream
70
+ // entry points — there is no raw-API shape to dispatch to.
71
+ const streams = {
72
+ stream: streamSimple,
73
+ streamSimple,
74
+ };
75
+ return (piAi as { createProvider: (input: unknown) => unknown }).createProvider({
76
+ id: PROVIDER_ID,
77
+ name: "Pi Claude",
78
+ baseUrl: "claude-bridge",
79
+ auth: {
80
+ apiKey: {
81
+ name: "Claude Code credentials",
82
+ // check() exists so pi's availability pass never has to call
83
+ // resolve(): both are existence-only, but check is the documented
84
+ // side-effect-free probe.
85
+ check: async () => (hasCredentials() ? { type: "api_key" as const, source: claudeAuthSourceLabel(env) } : undefined),
86
+ resolve: async () => (hasCredentials()
87
+ ? { auth: { apiKey: "not-used" }, source: claudeAuthSourceLabel(env) }
88
+ : undefined),
89
+ },
90
+ },
91
+ models: stamped,
92
+ api: streams,
93
+ });
94
+ }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from "fs";
2
2
  import { dirname, join, resolve } from "path";
3
3
  import { isolatedFromEnv, piUserDir } from "./config.js";
4
+ import { debug } from "./debug.js";
4
5
 
5
6
  export interface PromptContextSettings {
6
7
  includeAppendSystemPromptMd?: boolean;
@@ -19,7 +20,10 @@ function readTrimmed(path: string): string | undefined {
19
20
  if (!existsSync(path)) return undefined;
20
21
  const content = readFileSync(path, "utf8").trim();
21
22
  return content.length > 0 ? content : undefined;
22
- } catch {
23
+ } catch (error) {
24
+ // The file exists but could not be read: the user opted into forwarding
25
+ // it, so a silent drop looks like the setting being ignored.
26
+ debug(`prompt-context: failed to read ${path}:`, error instanceof Error ? error.message : String(error));
23
27
  return undefined;
24
28
  }
25
29
  }
@@ -0,0 +1,183 @@
1
+ // Pure assembly of the Claude Agent SDK query options for one bridge query.
2
+ // Extracted from index.ts (pure move): no closures — reads config, env, and
3
+ // the provided context only.
4
+
5
+ import { type Model } from "@earendil-works/pi-ai";
6
+ import { createSdkMcpServer, type query, type EffortLevel, type SettingSource } from "@anthropic-ai/claude-agent-sdk";
7
+ import { accountSessionScope, subscriberProfileEnv, type ClaudeAccountRoute } from "./account-router.js";
8
+ import { extractAgentsAppend } from "./agents-md.js";
9
+ import { spawnClaudeCodeWithDiagnostics } from "./claude-executable.js";
10
+ import { normalizeEffortLevel, type Config } from "./config.js";
11
+ import { connectorQueryOptions, connectorWriteModeFor, connectorsEnabledFor, settingSourcesForQuery } from "./connectors.js";
12
+ import { connectorServersSnapshot } from "./connector-runtime.js";
13
+ import { PROVIDER_ID } from "./convert.js";
14
+ import { makeCliDebugOptions } from "./debug.js";
15
+ import { FABLE_MODEL_ID, fallbackModelForPrimaryModel } from "./models.js";
16
+ import { buildPromptContextAppend } from "./prompt-context.js";
17
+ import { extractSkillsBlock } from "./skills.js";
18
+
19
+ // --- Effort level mapping ---
20
+ // Pi reasoning levels → CC SDK effort levels
21
+
22
+ const REASONING_TO_EFFORT: Record<string, EffortLevel> = {
23
+ minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "max", max: "max",
24
+ };
25
+
26
+ function normalizeEffortOverrideModelKey(value: string): string {
27
+ const key = value.trim().toLowerCase();
28
+ return key.startsWith(`${PROVIDER_ID}/`) ? key.slice(PROVIDER_ID.length + 1) : key;
29
+ }
30
+
31
+ export function resolveConfiguredEffort(
32
+ modelId: string,
33
+ reasoningEffort: EffortLevel | undefined,
34
+ providerConfig?: Config["provider"],
35
+ ): EffortLevel | undefined {
36
+ const target = normalizeEffortOverrideModelKey(modelId);
37
+ for (const [key, rawEffort] of Object.entries(providerConfig?.modelEffortOverrides ?? {})) {
38
+ const normalizedKey = normalizeEffortOverrideModelKey(key);
39
+ if (normalizedKey !== "*" && normalizedKey !== target) continue;
40
+ const effort = normalizeEffortLevel(rawEffort) as EffortLevel | undefined;
41
+ if (effort) return effort;
42
+ }
43
+ return (normalizeEffortLevel(providerConfig?.forceEffort) as EffortLevel | undefined) ?? reasoningEffort;
44
+ }
45
+
46
+ export interface BuildClaudeQueryOptionsInput {
47
+ cwd: string;
48
+ /** The model Pi requested. */
49
+ requestedModel: Model<any>;
50
+ /** The model this attempt actually runs (router may substitute). */
51
+ queryModel: Model<any>;
52
+ account?: ClaudeAccountRoute;
53
+ bridgeConfig: Config;
54
+ systemPrompt?: string;
55
+ /** Pi reasoning level from the stream options, if any. */
56
+ reasoning?: string;
57
+ resumeSessionId: string | null;
58
+ mcpServers?: Record<string, ReturnType<typeof createSdkMcpServer>>;
59
+ claudeExecutable?: string;
60
+ }
61
+
62
+ export interface BuiltClaudeQueryOptions {
63
+ queryOptions: NonNullable<Parameters<typeof query>[0]["options"]>;
64
+ // Diagnostics-ish bits the caller's debug line reports.
65
+ enableCloudMcp: boolean;
66
+ appendSystemPrompt: boolean;
67
+ promptContextLabels: string[];
68
+ strictMcpConfigEnabled: boolean;
69
+ effort?: EffortLevel;
70
+ fallbackModel?: string;
71
+ }
72
+
73
+ export function buildClaudeQueryOptions(input: BuildClaudeQueryOptionsInput): BuiltClaudeQueryOptions {
74
+ const { cwd, requestedModel, queryModel, account, bridgeConfig, systemPrompt, reasoning, resumeSessionId, mcpServers, claudeExecutable } = input;
75
+ const providerSettings = bridgeConfig.provider ?? {};
76
+ const accountScope = accountSessionScope(account);
77
+ // Whether to expose the Claude account's claude.ai cloud MCP connectors
78
+ // (Gmail/Calendar/Drive). Enabled via env or config; drives setting-sources,
79
+ // tool isolation, and the ENABLE_CLAUDEAI_MCP_SERVERS child-env gate below.
80
+ const enableCloudMcp = connectorsEnabledFor(bridgeConfig);
81
+ // Connector WRITE control: read-only by default (writes denied); the one-shot
82
+ // approved-write executor sets CLAUDE_BRIDGE_CONNECTOR_WRITE=allow / config.
83
+ const connectorWriteMode = connectorWriteModeFor(bridgeConfig);
84
+ // Declare the account's connected connectors explicitly so `alwaysLoad` can
85
+ // hold startup until they attach — otherwise the turn-1 manifest is built
86
+ // before the CLI has fetched them (vstack#832).
87
+ const connectorServers = enableCloudMcp ? connectorServersSnapshot(accountScope.claudeConfigDir) : {};
88
+ const appendSystemPrompt = providerSettings.appendSystemPrompt !== false;
89
+ const agentsAppend = appendSystemPrompt ? extractAgentsAppend() : undefined;
90
+ const skillsAppend = appendSystemPrompt ? extractSkillsBlock(systemPrompt) : undefined;
91
+ const promptContextAppend = buildPromptContextAppend(systemPrompt, cwd, bridgeConfig.promptContext ?? {});
92
+ const appendParts = [agentsAppend, skillsAppend, promptContextAppend.text].filter((part): part is string => Boolean(part));
93
+ const systemPromptAppend = appendParts.length > 0 ? appendParts.join("\n\n") : undefined;
94
+
95
+ // MCP auto-loading suppression: with appendSystemPrompt=true (default), the
96
+ // SDK uses isolation mode and avoids filesystem settings. If users turn that
97
+ // off, load user/project settings but pass --strict-mcp-config so Claude Code
98
+ // ignores auto-discovered filesystem MCP servers while Pi owns tool execution.
99
+ // Connectors mode needs settings resolution ON but restricted to USER scope
100
+ // only — project/local settings files can smuggle `env`/`apiKeyHelper` from
101
+ // a hostile checkout (vstack#990). Full rationale on settingSourcesForQuery.
102
+ const settingSources: SettingSource[] | undefined = settingSourcesForQuery(
103
+ enableCloudMcp, appendSystemPrompt, providerSettings.settingSources);
104
+ const strictMcpConfigEnabled = !appendSystemPrompt && providerSettings.strictMcpConfig !== false;
105
+ // Prefer the model's own thinkingLevelMap when present (pi-ai 0.72+ ships
106
+ // per-model overrides — e.g. opus-4-7 wants xhigh→xhigh, not xhigh→max).
107
+ // Fall back to our generic table for older pi-ai or unmapped levels.
108
+ const requestedEffort = reasoning
109
+ ? ((queryModel as any).thinkingLevelMap?.[reasoning] as EffortLevel | undefined)
110
+ ?? REASONING_TO_EFFORT[reasoning]
111
+ : undefined;
112
+ const effort = resolveConfiguredEffort(queryModel.id, requestedEffort, providerSettings);
113
+
114
+ const extraArgs: Record<string, string | null> = {};
115
+ // Opus 4.7 defaults thinking.display to "omitted" (empty thinking text in stream).
116
+ // Force summarized so thinking_delta events arrive. See anthropics/claude-agent-sdk-python#830.
117
+ // Deliberately the raw flag, NOT the typed `thinking` option: every non-disabled
118
+ // ThinkingConfig also emits `--thinking adaptive` or `--max-thinking-tokens`
119
+ // (verified in sdk.mjs flag mapping), so the typed form cannot set display
120
+ // without overriding the model's thinking mode alongside our `--effort`.
121
+ if (effort) extraArgs["thinking-display"] = "summarized";
122
+ // With a managed Fable pool, let every account's model-scoped allowance run
123
+ // out (rotation) before changing models — the CLI's own Opus fallback would
124
+ // silently skip accounts whose Fable quota is still available. Once the
125
+ // router explicitly selects Opus, its normal Opus→4.8 safety fallback is
126
+ // back on.
127
+ const fallbackModel = account && requestedModel.id === FABLE_MODEL_ID && queryModel.id === requestedModel.id
128
+ ? undefined
129
+ : fallbackModelForPrimaryModel(queryModel.id);
130
+
131
+ // Suppress claude.ai cloud MCP servers (Figma/Canva/etc. auto-discovered via OAuth
132
+ // when the user is logged into Anthropic). These are a separate code path from
133
+ // filesystem MCP and are NOT blocked by --strict-mcp-config or settingSources=undefined.
134
+ // The native CC binary gates them on env var ENABLE_CLAUDEAI_MCP_SERVERS: setting it
135
+ // to "0"/"false"/"no"/"off" makes the loader return early before any cloud fetch.
136
+ // DISABLE_AUTO_COMPACT=1: pi owns context-management and propagates its own
137
+ // /compact via session_compact (see handler in the extension entry). Letting CC
138
+ // also autocompact would double-flush the prompt cache and races pi's
139
+ // threshold with CC's, including CC's anti-thrashing guard (issue #8).
140
+ // Manual /compact in CC still works (we never invoke it).
141
+ // When connectors are enabled, allow claude.ai cloud MCP servers so the
142
+ // authenticated account's Gmail/Calendar/Drive tools load. Default stays "0".
143
+ const childEnv = {
144
+ ...(account ? subscriberProfileEnv(account) : process.env),
145
+ ENABLE_CLAUDEAI_MCP_SERVERS: enableCloudMcp ? "1" : "0",
146
+ DISABLE_AUTO_COMPACT: "1",
147
+ };
148
+ const queryOptions: NonNullable<Parameters<typeof query>[0]["options"]> = {
149
+ cwd,
150
+ model: queryModel.id,
151
+ env: childEnv,
152
+ ...connectorQueryOptions(enableCloudMcp, connectorWriteMode),
153
+ permissionMode: "bypassPermissions",
154
+ includePartialMessages: true,
155
+ ...(fallbackModel ? { fallbackModel } : {}),
156
+ ...(providerSettings.fastMode ? { settings: { fastMode: true } } : {}),
157
+ systemPrompt: {
158
+ type: "preset", preset: "claude_code",
159
+ append: systemPromptAppend ? systemPromptAppend : undefined,
160
+ },
161
+ extraArgs,
162
+ ...(strictMcpConfigEnabled ? { strictMcpConfig: true } : {}),
163
+ ...(effort ? { effort } : {}),
164
+ ...(settingSources ? { settingSources } : {}),
165
+ ...(mcpServers || Object.keys(connectorServers).length > 0
166
+ ? { mcpServers: { ...(mcpServers ?? {}), ...connectorServers } as NonNullable<Parameters<typeof query>[0]["options"]>["mcpServers"] }
167
+ : {}),
168
+ ...(resumeSessionId ? { resume: resumeSessionId } : {}),
169
+ ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
170
+ spawnClaudeCodeProcess: spawnClaudeCodeWithDiagnostics,
171
+ ...makeCliDebugOptions("provider"),
172
+ };
173
+
174
+ return {
175
+ queryOptions,
176
+ enableCloudMcp,
177
+ appendSystemPrompt,
178
+ promptContextLabels: promptContextAppend.labels,
179
+ strictMcpConfigEnabled,
180
+ ...(effort ? { effort } : {}),
181
+ ...(fallbackModel ? { fallbackModel } : {}),
182
+ };
183
+ }