@vanillagreen/pi-claude-bridge 1.6.2 → 1.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillagreen/pi-claude-bridge",
3
- "version": "1.6.2",
3
+ "version": "1.9.0",
4
4
  "description": "Pi provider bridge that runs Claude Code through the Claude Agent SDK, with opt-in forwarding for Pi prompt context.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -142,8 +142,8 @@
142
142
  }
143
143
  },
144
144
  "dependencies": {
145
- "@anthropic-ai/claude-agent-sdk": "0.3.158",
146
- "@anthropic-ai/sdk": "0.93.0",
145
+ "@anthropic-ai/claude-agent-sdk": "^0.3.215",
146
+ "@anthropic-ai/sdk": "^0.112.4",
147
147
  "cc-session-io": "^0.3.1",
148
148
  "change-case": "^5.4.4"
149
149
  },
@@ -152,15 +152,15 @@
152
152
  "@earendil-works/pi-coding-agent": "*"
153
153
  },
154
154
  "devDependencies": {
155
- "@earendil-works/pi-ai": "^0.75.0",
156
- "@earendil-works/pi-coding-agent": "^0.75.0",
155
+ "@earendil-works/pi-ai": "^0.80.10",
156
+ "@earendil-works/pi-coding-agent": "^0.80.10",
157
157
  "@types/node": "^24.3.0",
158
158
  "esbuild": "^0.28.0",
159
159
  "tsx": "^4.21.0",
160
160
  "typescript": "^6.0.3"
161
161
  },
162
162
  "scripts": {
163
- "build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node22 --outfile=bundle/index.js --external:@earendil-works/pi-ai --external:@earendil-works/pi-coding-agent",
163
+ "build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node22 --outfile=bundle/index.js --external:@earendil-works/pi-ai --external:@earendil-works/pi-coding-agent && esbuild src/connector-inventory.ts --bundle --platform=node --format=esm --target=node22 --outfile=bundle/connector-inventory.js",
164
164
  "prepack": "npm run build",
165
165
  "test:unit": "node --import tsx --test tests/unit-*.mjs",
166
166
  "test": "set -a && [ -f .env.test ] && . ./.env.test; set +a && npm run test:unit && tests/int-smoke.sh && tests/int-multi-turn.sh && tests/int-cache.sh && node --import tsx --test tests/int-*.mjs",
@@ -198,5 +198,9 @@
198
198
  "@earendil-works/pi-coding-agent": {
199
199
  "optional": true
200
200
  }
201
+ },
202
+ "exports": {
203
+ ".": "./bundle/index.js",
204
+ "./connector-inventory": "./bundle/connector-inventory.js"
201
205
  }
202
206
  }
package/src/agents-md.ts CHANGED
@@ -2,20 +2,28 @@
2
2
  //
3
3
  // Pi uses AGENTS.md for long-lived instructions; Claude Code reads the same
4
4
  // content under "# CLAUDE.md". We walk up from cwd looking for AGENTS.md,
5
- // fall back to ~/.pi/agent/AGENTS.md, and rewrite pi-specific references
5
+ // fall back to <piUserDir>/AGENTS.md (~/.pi/agent/AGENTS.md unless
6
+ // PI_CODING_AGENT_DIR points elsewhere), and rewrite pi-specific references
6
7
  // (~/.pi, .pi/, .pi, pi) to their Claude Code equivalents so any paths or
7
8
  // references in the file still resolve inside the CC subprocess.
9
+ //
10
+ // In isolated mode (CLAUDE_BRIDGE_ISOLATED=1), all AGENTS.md discovery is
11
+ // disabled. Embedding hosts provide their instruction surface explicitly.
8
12
 
9
13
  import { existsSync, readFileSync } from "fs";
10
- import { homedir } from "os";
11
14
  import { dirname, join, resolve } from "path";
15
+ import { isolatedFromEnv, piUserDir } from "./config.js";
12
16
 
13
- const GLOBAL_AGENTS_PATH = join(homedir(), ".pi", "agent", "AGENTS.md");
17
+ function globalAgentsPath(): string {
18
+ return join(piUserDir(), "AGENTS.md");
19
+ }
14
20
 
15
21
  export function resolveAgentsMdPath(): string | undefined {
22
+ if (isolatedFromEnv()) return undefined;
16
23
  const fromCwd = findAgentsMdInParents(process.cwd());
17
24
  if (fromCwd) return fromCwd;
18
- if (existsSync(GLOBAL_AGENTS_PATH)) return GLOBAL_AGENTS_PATH;
25
+ const globalPath = globalAgentsPath();
26
+ if (existsSync(globalPath)) return globalPath;
19
27
  return undefined;
20
28
  }
21
29
 
@@ -0,0 +1,307 @@
1
+ import { calculateCost, type AssistantMessage, type Model } from "@earendil-works/pi-ai";
2
+ import { type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
3
+ import { debug } from "./debug.js";
4
+ import { ctx } from "./query-state.js";
5
+ import { mapToolArgs, mapToolName } from "./tool-mapping.js";
6
+
7
+ // --- Usage helpers ---
8
+
9
+ function updateUsage(output: AssistantMessage, usage: Record<string, number | undefined>, model: Model<any>): void {
10
+ if (usage.input_tokens != null) output.usage.input = usage.input_tokens;
11
+ if (usage.output_tokens != null) output.usage.output = usage.output_tokens;
12
+ if (usage.cache_read_input_tokens != null) output.usage.cacheRead = usage.cache_read_input_tokens;
13
+ if (usage.cache_creation_input_tokens != null) output.usage.cacheWrite = usage.cache_creation_input_tokens;
14
+ output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
15
+ calculateCost(model, output.usage);
16
+ const promptTokens = output.usage.input + output.usage.cacheRead + output.usage.cacheWrite;
17
+ const cachePct = promptTokens > 0 ? Math.round(output.usage.cacheRead / promptTokens * 100) : 0;
18
+ debug(`usage: in=${output.usage.input} out=${output.usage.output} cacheRead=${output.usage.cacheRead} cacheWrite=${output.usage.cacheWrite} total=${output.usage.totalTokens} cachePct=${cachePct}% model=${model.id}`);
19
+ }
20
+
21
+ // --- Provider helpers: misc ---
22
+
23
+ function mapStopReason(reason: string | undefined): "stop" | "length" | "toolUse" {
24
+ switch (reason) {
25
+ case "tool_use": return "toolUse";
26
+ case "max_tokens": return "length";
27
+ case "end_turn": default: return "stop";
28
+ }
29
+ }
30
+
31
+ export function parsePartialJson(input: string, fallback: Record<string, unknown>): Record<string, unknown> {
32
+ if (!input) return fallback;
33
+ try { return JSON.parse(input); } catch { return fallback; }
34
+ }
35
+
36
+ export function ensureTurnStarted(): void {
37
+ if (!ctx().turnStarted && ctx().currentPiStream && ctx().turnOutput) {
38
+ ctx().currentPiStream!.push({ type: "start", partial: ctx().turnOutput });
39
+ ctx().turnStarted = true;
40
+ }
41
+ }
42
+
43
+ export function finalizeCurrentStream(stopReason?: string): void {
44
+ if (!ctx().currentPiStream || !ctx().turnOutput) return;
45
+ debug(`provider: finalizeCurrentStream called, stopReason=${stopReason}, turnOutput=${JSON.stringify({stopReason: ctx().turnOutput!.stopReason, error: ctx().turnOutput!.errorMessage})}`);
46
+ if (!ctx().turnStarted) ensureTurnStarted();
47
+ const reason = stopReason === "length" ? "length" : "stop";
48
+ ctx().currentPiStream!.push({ type: "done", reason, message: ctx().turnOutput });
49
+ ctx().currentPiStream!.end();
50
+ ctx().currentPiStream = null;
51
+ }
52
+
53
+ export function updateTurnOutputModel(modelId: unknown): void {
54
+ const c = ctx();
55
+ if (typeof modelId !== "string" || !modelId || !c.turnOutput) return;
56
+ if (c.turnOutput.model === modelId) return;
57
+ debug(`provider: active Claude model changed ${c.turnOutput.model} -> ${modelId}`);
58
+ c.turnOutput.model = modelId;
59
+ }
60
+
61
+ /** Maps Anthropic stream events to pi stream events (text, thinking, toolcall).
62
+ * On message_stop with tool_use: ends currentPiStream so pi can execute the tool. */
63
+ export function processStreamEvent(
64
+ message: SDKMessage,
65
+ customToolNameToPi: Map<string, string>,
66
+ model: Model<any>,
67
+ ): void {
68
+ const c = ctx();
69
+ if (!c.currentPiStream || !c.turnOutput) return;
70
+ const event = (message as SDKMessage & { event: any }).event;
71
+ if (event?.type === "ping") return;
72
+ if (event?.type === "message_stop" && !c.turnSawToolCall) {
73
+ debug("processStreamEvent: ignoring bare message_stop with no streamed content/tool call");
74
+ return;
75
+ }
76
+
77
+ if (event?.type === "message_start") {
78
+ c.resetToolTracking();
79
+ updateTurnOutputModel(event.message?.model);
80
+ if (event.message?.usage) updateUsage(c.turnOutput, event.message.usage, model);
81
+ return;
82
+ }
83
+
84
+ if (event?.type === "content_block_start") {
85
+ c.turnSawStreamEvent = true;
86
+ ensureTurnStarted();
87
+ if (event.content_block?.type === "text") {
88
+ c.turnBlocks.push({ type: "text", text: "", index: event.index });
89
+ c.currentPiStream!.push({ type: "text_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
90
+ } else if (event.content_block?.type === "thinking") {
91
+ c.turnBlocks.push({ type: "thinking", thinking: "", thinkingSignature: "", index: event.index });
92
+ c.currentPiStream!.push({ type: "thinking_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
93
+ } else if (event.content_block?.type === "tool_use") {
94
+ c.turnSawToolCall = true;
95
+ const mappedName = mapToolName(event.content_block.name, customToolNameToPi);
96
+ c.recordToolCall(event.content_block.id, mappedName, {});
97
+ c.turnBlocks.push({
98
+ type: "toolCall", id: event.content_block.id,
99
+ name: mappedName,
100
+ arguments: (event.content_block.input as Record<string, unknown>) ?? {},
101
+ partialJson: "", index: event.index,
102
+ });
103
+ c.currentPiStream!.push({ type: "toolcall_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
104
+ } else {
105
+ debug("processStreamEvent: unhandled content_block_start type", event.content_block?.type);
106
+ }
107
+ return;
108
+ }
109
+
110
+ if (event?.type === "content_block_delta") {
111
+ const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
112
+ const block = c.turnBlocks[index];
113
+ if (!block) {
114
+ debug("processStreamEvent: ignoring unmatched content_block_delta", event.index);
115
+ return;
116
+ }
117
+ c.turnSawStreamEvent = true;
118
+ if (event.delta?.type === "text_delta" && block.type === "text") {
119
+ block.text += event.delta.text;
120
+ c.currentPiStream!.push({ type: "text_delta", contentIndex: index, delta: event.delta.text, partial: c.turnOutput });
121
+ } else if (event.delta?.type === "thinking_delta" && block.type === "thinking") {
122
+ block.thinking += event.delta.thinking;
123
+ c.currentPiStream!.push({ type: "thinking_delta", contentIndex: index, delta: event.delta.thinking, partial: c.turnOutput });
124
+ } else if (event.delta?.type === "input_json_delta" && block.type === "toolCall") {
125
+ block.partialJson += event.delta.partial_json;
126
+ block.arguments = parsePartialJson(block.partialJson, block.arguments);
127
+ c.currentPiStream!.push({ type: "toolcall_delta", contentIndex: index, delta: event.delta.partial_json, partial: c.turnOutput });
128
+ } else if (event.delta?.type === "signature_delta" && block.type === "thinking") {
129
+ block.thinkingSignature = (block.thinkingSignature ?? "") + event.delta.signature;
130
+ } else {
131
+ debug("processStreamEvent: unhandled content_block_delta type", event.delta?.type);
132
+ }
133
+ return;
134
+ }
135
+
136
+ if (event?.type === "content_block_stop") {
137
+ const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
138
+ const block = c.turnBlocks[index];
139
+ if (!block) {
140
+ debug("processStreamEvent: ignoring unmatched content_block_stop", event.index);
141
+ return;
142
+ }
143
+ c.turnSawStreamEvent = true;
144
+ delete block.index;
145
+ if (block.type === "text") {
146
+ c.currentPiStream!.push({ type: "text_end", contentIndex: index, content: block.text, partial: c.turnOutput });
147
+ } else if (block.type === "thinking") {
148
+ c.currentPiStream!.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: c.turnOutput });
149
+ } else if (block.type === "toolCall") {
150
+ c.turnSawToolCall = true;
151
+ block.arguments = mapToolArgs(
152
+ block.name, parsePartialJson(block.partialJson, block.arguments),
153
+ );
154
+ c.updateToolCallArgs(block.id, block.arguments);
155
+ delete block.partialJson;
156
+ c.currentPiStream!.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: c.turnOutput });
157
+ }
158
+ return;
159
+ }
160
+
161
+ if (event?.type === "message_delta") {
162
+ c.turnOutput.stopReason = mapStopReason(event.delta?.stop_reason);
163
+ if (event.usage) updateUsage(c.turnOutput, event.usage, model);
164
+ return;
165
+ }
166
+
167
+ if (event?.type === "message_stop" && c.turnSawToolCall) {
168
+ // Tool call complete — end this pi stream. The SDK will still yield an
169
+ // assistant message for this turn, but currentPiStream=null causes
170
+ // consumeQuery to skip it. The MCP handler blocks the generator until
171
+ // pi delivers the tool result via the next streamSimple call.
172
+ c.turnOutput.stopReason = "toolUse";
173
+ c.currentPiStream!.push({ type: "done", reason: "toolUse", message: c.turnOutput });
174
+ c.currentPiStream!.end();
175
+ c.currentPiStream = null;
176
+
177
+ // Cursor is updated by the next streamSimple call (tool result delivery path)
178
+ // which sets cursor = context.messages.length with the post-tool-result context.
179
+ return;
180
+ }
181
+
182
+ if (event?.type !== "message_stop" && event?.type !== "ping") {
183
+ debug("processStreamEvent: unhandled event type", event?.type);
184
+ }
185
+ }
186
+
187
+ // The SDK always yields `assistant` messages (completed content blocks) after streaming.
188
+ // When stream_events already delivered the content, this is a no-op. But after
189
+ // resetTurnState (e.g. tool result delivery), if the next turn's assistant message
190
+ // arrives before any stream_events, this is the primary content path. Must maintain
191
+ // the same stream lifecycle as processStreamEvent — including ending the stream on
192
+ // tool_use to prevent deadlock with the MCP handler.
193
+ function appendMissingToolUsesFromAssistant(
194
+ assistantMsg: { content?: Array<any>; usage?: Record<string, number | undefined> },
195
+ model: Model<any>,
196
+ customToolNameToPi: Map<string, string>,
197
+ ): boolean {
198
+ const c = ctx();
199
+ if (!assistantMsg?.content) return false;
200
+ let sawToolUse = false;
201
+ for (const block of assistantMsg.content) {
202
+ if (block.type !== "tool_use") continue;
203
+ sawToolUse = true;
204
+ const existingIdx = c.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === block.id);
205
+ const name = mapToolName(block.name, customToolNameToPi);
206
+ const mappedArgs = mapToolArgs(name, block.input);
207
+ c.recordToolCall(block.id, name, mappedArgs);
208
+ if (existingIdx >= 0) {
209
+ const existing = c.turnBlocks[existingIdx] as any;
210
+ existing.name = name;
211
+ existing.arguments = mappedArgs;
212
+ c.updateToolCallArgs(block.id, mappedArgs);
213
+ if ("partialJson" in existing) {
214
+ delete existing.partialJson;
215
+ delete existing.index;
216
+ c.currentPiStream?.push({ type: "toolcall_end", contentIndex: existingIdx, toolCall: existing, partial: c.turnOutput });
217
+ }
218
+ continue;
219
+ }
220
+
221
+ ensureTurnStarted();
222
+ c.turnBlocks.push({
223
+ type: "toolCall", id: block.id,
224
+ name,
225
+ arguments: mappedArgs,
226
+ });
227
+ const idx = c.turnBlocks.length - 1;
228
+ const toolBlock = c.turnBlocks[idx];
229
+ c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
230
+ c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
231
+ }
232
+ if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model);
233
+ return sawToolUse;
234
+ }
235
+
236
+ export function processAssistantMessage(message: SDKMessage, model: Model<any>, customToolNameToPi: Map<string, string>): void {
237
+ const c = ctx();
238
+ const assistantMsg = (message as any).message;
239
+ if (!assistantMsg?.content) return;
240
+ updateTurnOutputModel(assistantMsg.model);
241
+ if (c.turnSawStreamEvent) {
242
+ // Claude Agent SDK can yield the completed assistant message before (or
243
+ // instead of) a stream_event message_stop for a tool-use turn. Treat that
244
+ // assistant message as a hard turn boundary so Pi executes the tool calls
245
+ // and the MCP handlers stay blocked until real tool results are delivered.
246
+ // Without this fallback, Claude Code can continue internally with empty MCP
247
+ // results and Pi only sees the real outputs one render cycle later.
248
+ if (appendMissingToolUsesFromAssistant(assistantMsg, model, customToolNameToPi)) {
249
+ c.turnSawToolCall = true;
250
+ if (c.currentPiStream && c.turnOutput) {
251
+ c.turnOutput.stopReason = "toolUse";
252
+ c.currentPiStream.push({ type: "done", reason: "toolUse", message: c.turnOutput });
253
+ c.currentPiStream.end();
254
+ c.currentPiStream = null;
255
+ debug("processAssistantMessage boundary: ended streamed tool_use turn from assistant message");
256
+ }
257
+ }
258
+ return;
259
+ }
260
+ c.resetToolTracking();
261
+ debug(`processAssistantMessage fallback: ${assistantMsg.content.length} blocks, types=${assistantMsg.content.map((b: any) => b.type).join(",")}`);
262
+ for (const block of assistantMsg.content) {
263
+ if (block.type === "text" && block.text) {
264
+ ensureTurnStarted();
265
+ c.turnBlocks.push({ type: "text", text: block.text });
266
+ const idx = c.turnBlocks.length - 1;
267
+ c.currentPiStream?.push({ type: "text_start", contentIndex: idx, partial: c.turnOutput });
268
+ c.currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: block.text, partial: c.turnOutput });
269
+ c.currentPiStream?.push({ type: "text_end", contentIndex: idx, content: block.text, partial: c.turnOutput });
270
+ } else if (block.type === "thinking") {
271
+ ensureTurnStarted();
272
+ c.turnBlocks.push({ type: "thinking", thinking: block.thinking ?? "", thinkingSignature: block.signature ?? "" });
273
+ const idx = c.turnBlocks.length - 1;
274
+ c.currentPiStream?.push({ type: "thinking_start", contentIndex: idx, partial: c.turnOutput });
275
+ if (block.thinking) c.currentPiStream?.push({ type: "thinking_delta", contentIndex: idx, delta: block.thinking, partial: c.turnOutput });
276
+ c.currentPiStream?.push({ type: "thinking_end", contentIndex: idx, content: block.thinking ?? "", partial: c.turnOutput });
277
+ } else if (block.type === "tool_use") {
278
+ ensureTurnStarted();
279
+ c.turnSawToolCall = true;
280
+ const mappedName = mapToolName(block.name, customToolNameToPi);
281
+ const mappedArgs = mapToolArgs(mappedName, block.input);
282
+ c.recordToolCall(block.id, mappedName, mappedArgs);
283
+ c.turnBlocks.push({
284
+ type: "toolCall", id: block.id,
285
+ name: mappedName,
286
+ arguments: mappedArgs,
287
+ });
288
+ const idx = c.turnBlocks.length - 1;
289
+ const toolBlock = c.turnBlocks[idx];
290
+ c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
291
+ c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
292
+ } else if (block.type === "fallback") {
293
+ updateTurnOutputModel(block.to?.model);
294
+ } else {
295
+ debug("processAssistantMessage: unhandled block type", block.type);
296
+ }
297
+ }
298
+ if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model);
299
+
300
+ // End the stream on tool_use, same as processStreamEvent's message_stop handler.
301
+ if (c.turnSawToolCall && c.currentPiStream && c.turnOutput) {
302
+ c.turnOutput.stopReason = "toolUse";
303
+ c.currentPiStream.push({ type: "done", reason: "toolUse", message: c.turnOutput });
304
+ c.currentPiStream.end();
305
+ c.currentPiStream = null;
306
+ }
307
+ }
@@ -0,0 +1,158 @@
1
+ // --- Claude credential presence (availability honesty) ---
2
+ //
3
+ // The bridge may only advertise claude-bridge models when the machine actually
4
+ // has Claude credentials the Claude Agent SDK can authenticate with. Otherwise
5
+ // pi's ModelRegistry.hasConfiguredAuth() would treat the dummy `apiKey:
6
+ // "not-used"` as "configured" and the provider would look connected while every
7
+ // request fails at spawn time.
8
+ //
9
+ // This module answers two pure questions used to gate registration:
10
+ // 1. hasClaudeCredentials() — are real credentials present RIGHT NOW?
11
+ // 2. decideRegistration() — given credential presence + the primary-instance
12
+ // / stream-guard tokens, should we register / unregister / do nothing?
13
+ //
14
+ // SECURITY: this module only ever checks for the EXISTENCE of credentials — a
15
+ // file's presence, an env var being non-empty, a settings key being a non-empty
16
+ // string. It NEVER opens or logs `.credentials.json`, and where it must parse
17
+ // `settings.json` (for apiKeyHelper) it reads only whether the key is a
18
+ // non-empty string and never logs its value. Credential CONTENTS are never read
19
+ // or logged.
20
+
21
+ import { existsSync, readFileSync } from "fs";
22
+ import { homedir, platform as osPlatform } from "os";
23
+ import { join } from "path";
24
+
25
+ /**
26
+ * Resolve the Claude config directory the same way the bundled cc-session-io
27
+ * (getClaudeDir) and Claude Code itself resolve it: an explicit
28
+ * CLAUDE_CONFIG_DIR wins, otherwise ~/.claude.
29
+ *
30
+ * Deliberate divergence from cc-session-io's plain `env ?? default`: we treat a
31
+ * SET-BUT-EMPTY/whitespace CLAUDE_CONFIG_DIR as unset and fall back to ~/.claude
32
+ * (an empty string would otherwise resolve credential probes to the process cwd
33
+ * root). The returned value is trimmed so downstream joins never carry stray
34
+ * whitespace.
35
+ */
36
+ export function resolveClaudeConfigDir(env: NodeJS.ProcessEnv = process.env): string {
37
+ const configured = env.CLAUDE_CONFIG_DIR;
38
+ if (typeof configured === "string" && configured.trim().length > 0) return configured.trim();
39
+ return join(homedir(), ".claude");
40
+ }
41
+
42
+ function nonEmptyEnv(value: string | undefined): boolean {
43
+ return typeof value === "string" && value.trim().length > 0;
44
+ }
45
+
46
+ // Matches how Claude Code interprets its boolean provider-routing env flags:
47
+ // only "1" / "true" (case-insensitive) enable them.
48
+ function envTruthy(value: string | undefined): boolean {
49
+ const v = value?.trim().toLowerCase();
50
+ return v === "1" || v === "true";
51
+ }
52
+
53
+ /**
54
+ * True when `${configDir}/settings.json` exists, parses as JSON, and carries a
55
+ * non-empty `apiKeyHelper` string (an enterprise/custom auth command that
56
+ * produces a key). Parse/read errors are tolerated as "not present". The helper
57
+ * VALUE is never logged — only its presence/non-emptiness is used.
58
+ */
59
+ function hasApiKeyHelper(configDir: string): boolean {
60
+ try {
61
+ const settingsPath = join(configDir, "settings.json");
62
+ if (!existsSync(settingsPath)) return false;
63
+ const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as { apiKeyHelper?: unknown };
64
+ return typeof parsed?.apiKeyHelper === "string" && parsed.apiKeyHelper.trim().length > 0;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * True when real Claude credentials are present in any location the Claude Agent
72
+ * SDK would authenticate from, WITHOUT reading credential contents. Checked in
73
+ * cheap-first order:
74
+ * - env: non-empty CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY /
75
+ * ANTHROPIC_AUTH_TOKEN, or any truthy cloud-provider routing flag the Claude
76
+ * Code SDK recognizes — CLAUDE_CODE_USE_BEDROCK / _VERTEX / _FOUNDRY /
77
+ * _ANTHROPIC_AWS / _MANTLE (such routing needs no local key file);
78
+ * - `.credentials.json` in the resolved config dir (existence only — the file
79
+ * is never opened; written mode 0600 by `claude login`);
80
+ * - `settings.json` apiKeyHelper (presence only — see hasApiKeyHelper).
81
+ *
82
+ * PLATFORM ASYMMETRY: on macOS the `claude` CLI stores OAuth tokens in the login
83
+ * Keychain, NOT in `.credentials.json`, so file-absence is NOT evidence of
84
+ * logged-out and we cannot cheaply/safely probe the Keychain here. On darwin,
85
+ * when no other signal is present, we default to credentialed=true — preserving
86
+ * the pre-fix "always available" behavior for Mac subscription users. Honesty
87
+ * enforcement therefore applies on Linux/Windows, where `.credentials.json`
88
+ * existence is an observable, truthful proxy (empirically, `claude auth logout`
89
+ * unlinks it).
90
+ */
91
+ export function hasClaudeCredentials(
92
+ env: NodeJS.ProcessEnv = process.env,
93
+ platform: NodeJS.Platform = osPlatform(),
94
+ ): boolean {
95
+ if (nonEmptyEnv(env.CLAUDE_CODE_OAUTH_TOKEN)) return true;
96
+ if (nonEmptyEnv(env.ANTHROPIC_API_KEY)) return true;
97
+ if (nonEmptyEnv(env.ANTHROPIC_AUTH_TOKEN)) return true;
98
+ if (envTruthy(env.CLAUDE_CODE_USE_BEDROCK)) return true;
99
+ if (envTruthy(env.CLAUDE_CODE_USE_VERTEX)) return true;
100
+ if (envTruthy(env.CLAUDE_CODE_USE_FOUNDRY)) return true;
101
+ if (envTruthy(env.CLAUDE_CODE_USE_ANTHROPIC_AWS)) return true;
102
+ if (envTruthy(env.CLAUDE_CODE_USE_MANTLE)) return true;
103
+
104
+ const configDir = resolveClaudeConfigDir(env);
105
+ if (existsSync(join(configDir, ".credentials.json"))) return true;
106
+ if (hasApiKeyHelper(configDir)) return true;
107
+
108
+ if (platform === "darwin") return true;
109
+
110
+ return false;
111
+ }
112
+
113
+ /**
114
+ * Snapshot of the inputs to a registration decision.
115
+ *
116
+ * The bridge keeps two process-global tokens (Symbol.for): a PRIMARY-instance
117
+ * token, claimed unconditionally by the first-loaded module instance, and the
118
+ * stream-guard token holding the registered instance's streamSimple. ONLY the
119
+ * primary instance may ever register/unregister or claim the stream guard — this
120
+ * prevents a subagent module reload (a fresh, non-primary instance) from
121
+ * stealing ownership and registering ITS streamSimple, which would split-brain
122
+ * the shared session/ctx and break tool-result delivery.
123
+ */
124
+ export interface RegistrationState {
125
+ /** Does the machine have Claude credentials right now? */
126
+ credentialed: boolean;
127
+ /** Is THIS module instance the primary (first-loaded) instance? */
128
+ isPrimary: boolean;
129
+ /** Has this instance already registered (owns the stream guard)? */
130
+ registered: boolean;
131
+ }
132
+
133
+ export type RegistrationDecision = "register" | "unregister" | "noop";
134
+
135
+ /**
136
+ * Pure decision for extension load, every session_start re-check, and the
137
+ * pre-spawn fail-fast path.
138
+ *
139
+ * Rules:
140
+ * - Not the primary instance → NOOP (never touch registration).
141
+ * - Primary + credentialed + not registered → REGISTER (claim guard + register).
142
+ * - Primary + credentialed + already registered → NOOP.
143
+ * - Primary + uncredentialed → UNREGISTER (defensive).
144
+ *
145
+ * The uncredentialed primary always returns UNREGISTER rather than NOOP:
146
+ * pi.unregisterProvider is idempotent ("Has no effect if the provider was never
147
+ * registered"), and a defensive call is the ONLY way to retract a registration
148
+ * that survived a /reload — the ModelRegistry's registeredProviders is a
149
+ * process-lifetime Map and module reload does NOT clear it. (At extension-load
150
+ * time this defensive unregister only filters the pending-registration queue and
151
+ * cannot mutate the persistent registry; the authoritative retraction happens on
152
+ * the post-load session_start re-check — see applyProviderRegistration.)
153
+ */
154
+ export function decideRegistration(state: RegistrationState): RegistrationDecision {
155
+ if (!state.isPrimary) return "noop";
156
+ if (state.credentialed) return state.registered ? "noop" : "register";
157
+ return "unregister";
158
+ }