@vanillagreen/pi-claude-bridge 4.0.1 → 4.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillagreen/pi-claude-bridge",
3
- "version": "4.0.1",
3
+ "version": "4.0.2",
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": [
@@ -145,13 +145,14 @@
145
145
  "devDependencies": {
146
146
  "@earendil-works/pi-ai": "^0.84.0",
147
147
  "@earendil-works/pi-coding-agent": "^0.84.0",
148
+ "@modelcontextprotocol/sdk": "^1.30.0",
148
149
  "@types/node": "^24.3.0",
149
150
  "esbuild": "^0.28.0",
150
151
  "tsx": "^4.21.0",
151
152
  "typescript": "^6.0.3"
152
153
  },
153
154
  "scripts": {
154
- "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",
155
+ "build": "esbuild src/index.ts --bundle --preserve-symlinks --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 --preserve-symlinks --platform=node --format=esm --target=node22 --outfile=bundle/connector-inventory.js",
155
156
  "prepack": "npm run build",
156
157
  "test:unit": "node --import tsx --test tests/unit-*.mjs",
157
158
  "test:ci": "npm run build && npm run test:unit",
@@ -159,6 +159,7 @@ export function reapStaleQueuedResults(c: QueryContext): void {
159
159
  diagDump("stale_queued_tool_results_parked", { count: stale.length, stale });
160
160
  appendIntegrityEntry("stale_queued_tool_results_parked", { count: stale.length, stale });
161
161
  safeNotify(
162
+ `queued-results-parked=${JSON.stringify({ count: stale.length, tools: names })}\n` +
162
163
  `Claude bridge: parked ${stale.length} early tool result(s) whose handler has not arrived (${names.slice(0, 6).join(", ")}${names.length > 6 ? ", …" : ""}). ` +
163
164
  `A late handler can still consume them.`,
164
165
  "warning",
@@ -345,7 +346,7 @@ export function processStreamEvent(
345
346
  }
346
347
  if (event.content_block?.type === "tool_use" && !isPiDispatchable(event.content_block.name, customToolNameToPi)) {
347
348
  c.suppressedStreamIndexes.add(event.index);
348
- if (isForeignMcpTool(event.content_block.name)) c.markOutputCommitted();
349
+ if (isForeignMcpTool(event.content_block.name)) c.noteForeignMcpToolCall(event.content_block.id, event.content_block.name);
349
350
  debug(`processStreamEvent: non-dispatchable tool ${event.content_block.name} [${event.content_block.id}] — not mirrored as a Pi tool call`);
350
351
  return;
351
352
  }
@@ -513,7 +514,7 @@ function appendMissingToolUsesFromAssistant(
513
514
  continue;
514
515
  }
515
516
  if (!isPiDispatchable(block.name, customToolNameToPi)) {
516
- if (isForeignMcpTool(block.name)) c.markOutputCommitted();
517
+ if (isForeignMcpTool(block.name)) c.noteForeignMcpToolCall(block.id, block.name);
517
518
  debug(`assistant message: non-dispatchable tool ${block.name} [${block.id}] — not mirrored as a Pi tool call`);
518
519
  continue;
519
520
  }
@@ -670,7 +671,7 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
670
671
  continue;
671
672
  }
672
673
  if (!isPiDispatchable(block.name, customToolNameToPi)) {
673
- if (isForeignMcpTool(block.name)) c.markOutputCommitted();
674
+ if (isForeignMcpTool(block.name)) c.noteForeignMcpToolCall(block.id, block.name);
674
675
  debug(`processAssistantMessage fallback: non-dispatchable tool ${block.name} [${block.id}] — not mirrored as a Pi tool call`);
675
676
  continue;
676
677
  }
@@ -0,0 +1,116 @@
1
+ // The Anthropic login the bridge's child query actually authenticated as,
2
+ // published for other extensions to read.
3
+ //
4
+ // WHY a published surface rather than letting a reader resolve
5
+ // CLAUDE_CONFIG_DIR for itself: that directory names the login only when the
6
+ // child used one. The bridge also accepts an API key and the Bedrock, Vertex,
7
+ // Foundry, Anthropic-AWS and Mantle backends (auth-presence.ts), and it passes
8
+ // those environment values straight to the child (query-options.ts). A
9
+ // companion account router may additionally hand each request its own profile
10
+ // and rotate it while the process environment never changes
11
+ // (account-router.ts). Only the SDK's own accountInfo() names the identity a
12
+ // request ran under, so the rule for reading it lives here once instead of in
13
+ // every consumer.
14
+ //
15
+ // SECURITY: this module holds one email per live request lane in memory and
16
+ // never logs it.
17
+
18
+ import type { AccountInfo } from "@anthropic-ai/claude-agent-sdk";
19
+ import { currentRequestLaneId } from "./request-lane.js";
20
+
21
+ export const CLAUDE_BILLING_IDENTITY_SYMBOL = Symbol.for("kendex.pi.claude-bridge.billing-identity.v1");
22
+
23
+ /** Anthropic's own login backend. Every other `apiProvider` value is an
24
+ * external credential whose payer this bridge cannot name. */
25
+ const FIRST_PARTY = "firstParty";
26
+
27
+ export interface ClaudeBillingIdentityV1 {
28
+ version: 1;
29
+ /** The Anthropic login email of the latest child attempt in `sessionId`, or
30
+ * undefined when that attempt authenticated with an API key or a
31
+ * third-party backend, has not reported yet, or its probe failed. A
32
+ * consumer passes the visible Pi session id, displays the result, and
33
+ * derives nothing further. */
34
+ currentLoginEmail(sessionId: string | undefined): string | undefined;
35
+ }
36
+
37
+ interface BillingIdentityStore extends ClaudeBillingIdentityV1 {
38
+ beginAttempt(sessionId: string | undefined): (info: AccountInfo) => void;
39
+ deleteLane(sessionId: string | undefined): void;
40
+ clear(): void;
41
+ }
42
+
43
+ interface BillingIdentityLane {
44
+ attempt: symbol;
45
+ loginEmail?: string;
46
+ }
47
+
48
+ const BILLING_IDENTITY_LANES_SYMBOL = Symbol.for("kendex.pi.claude-bridge.billing-identity-lanes.v1");
49
+
50
+ function sharedBillingIdentityLanes(): Map<string | undefined, BillingIdentityLane> {
51
+ const host = globalThis as Record<symbol, unknown>;
52
+ let lanes = host[BILLING_IDENTITY_LANES_SYMBOL] as Map<string | undefined, BillingIdentityLane> | undefined;
53
+ if (!lanes) {
54
+ lanes = new Map();
55
+ host[BILLING_IDENTITY_LANES_SYMBOL] = lanes;
56
+ }
57
+ return lanes;
58
+ }
59
+
60
+ function nonEmpty(value: string | undefined): string | undefined {
61
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
62
+ }
63
+
64
+ /** The login email an `accountInfo()` result confirms, or undefined when it
65
+ * confirms none. An API key is rejected even under the first-party backend:
66
+ * the key's owner is not the signed-in login, and `apiKeySource` is how the
67
+ * SDK reports that a key was used. */
68
+ export function loginEmailFrom(info: AccountInfo): string | undefined {
69
+ if (info.apiProvider !== FIRST_PARTY) return undefined;
70
+ if (nonEmpty(info.apiKeySource)) return undefined;
71
+ return nonEmpty(info.email);
72
+ }
73
+
74
+ export function makeBillingIdentityStore(
75
+ lanes: Map<string | undefined, BillingIdentityLane> = new Map(),
76
+ ): BillingIdentityStore {
77
+ return {
78
+ version: 1,
79
+ currentLoginEmail: (sessionId) => lanes.get(sessionId)?.loginEmail,
80
+ beginAttempt: (sessionId) => {
81
+ const attempt = Symbol("billing-identity-attempt");
82
+ lanes.set(sessionId, { attempt });
83
+ return (info) => {
84
+ const current = lanes.get(sessionId);
85
+ if (current?.attempt !== attempt) return;
86
+ lanes.set(sessionId, { attempt, loginEmail: loginEmailFrom(info) });
87
+ };
88
+ },
89
+ deleteLane: (sessionId) => lanes.delete(sessionId),
90
+ clear: () => lanes.clear(),
91
+ };
92
+ }
93
+
94
+ export const BRIDGE_BILLING_IDENTITY = makeBillingIdentityStore(sharedBillingIdentityLanes());
95
+
96
+ /** Start the billing probe for the current request lane. Starting clears that
97
+ * lane, so a rejected probe cannot leave the previous attempt's identity.
98
+ * The returned recorder ignores an older probe that settles after a newer
99
+ * attempt in the same lane. */
100
+ export function beginBillingIdentityAttempt(): (info: AccountInfo) => void {
101
+ return BRIDGE_BILLING_IDENTITY.beginAttempt(currentRequestLaneId());
102
+ }
103
+
104
+ /** Remove one completed Pi session without changing concurrent sessions. */
105
+ export function deleteBillingIdentityLane(sessionId: string | undefined): void {
106
+ BRIDGE_BILLING_IDENTITY.deleteLane(sessionId);
107
+ }
108
+
109
+ /** Read the published store, or undefined when no bridge is loaded. Never
110
+ * installs one: a consumer that created its own would answer for a bridge
111
+ * that is not running. */
112
+ export function resolveClaudeBillingIdentity(): ClaudeBillingIdentityV1 | undefined {
113
+ const host = globalThis as unknown as Record<PropertyKey, unknown>;
114
+ const candidate = host[CLAUDE_BILLING_IDENTITY_SYMBOL] as ClaudeBillingIdentityV1 | undefined;
115
+ return candidate?.version === 1 && typeof candidate.currentLoginEmail === "function" ? candidate : undefined;
116
+ }
@@ -1,5 +1,5 @@
1
1
  import { type ExtensionAPI, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
- import { debug, diagDump, diagGuidance } from "./debug.js";
2
+ import { DEBUG, debug, diagDump, diagGuidance, diagLogPath } from "./debug.js";
3
3
  import { type QueryContext } from "./query-state.js";
4
4
  import { currentRequestLaneId } from "./request-lane.js";
5
5
  import { summarizeMissingToolNames, type MissingToolResult } from "./tool-pairing-audit.js";
@@ -35,14 +35,16 @@ export interface SessionState {
35
35
  // navigation) or after an abort left the JSONL in an indeterminate state.
36
36
  // REBUILD wipes and rewrites the file to match pi's current history.
37
37
  needsRebuild?: boolean;
38
- // Set ONLY after an abort. The killed CC subprocess may still be flushing
39
- // a late "[Request interrupted by user]" record to the session JSONL.
38
+ // Set whenever a CC subprocess was killed and may still be flushing to its
39
+ // session JSONL: after an abort, whose late "[Request interrupted by user]"
40
+ // record is the classic case, and at a history replacement (compact, tree
41
+ // navigation) that stops an ACTIVE query to restart it on pi's new context.
40
42
  // Reusing the same sessionId/path would race that orphan write into our
41
43
  // fresh file and break CC's parent-uuid chain on the next resume. When
42
44
  // this flag is set, REBUILD takes a fresh UUID and skips deleteSession
43
- // so the orphan writes land on a dead inode. Compact/tree do NOT set
44
- // this — there's no concurrent CC writer during those events, so
45
- // in-place rebuild (preserve UUID, deleteSession + createSession) is safe.
45
+ // so the orphan writes land on a dead inode. A compact or tree event with
46
+ // no active query kills nothing, so it leaves this unset and rebuilds in
47
+ // place (preserve UUID, deleteSession + createSession).
46
48
  forceRotate?: boolean;
47
49
  }
48
50
 
@@ -284,6 +286,7 @@ export function reportToolResultMismatch(
284
286
  unmatchedResultIds: progress.unmatchedResultIds,
285
287
  });
286
288
  safeNotify(
289
+ `tool-result-mismatch=${JSON.stringify({ delivered: progress.deliveredCount, expected: progress.expectedCount, resolved: progress.resolvedCount, diagnostic: DEBUG ? diagLogPath() : "CLAUDE_BRIDGE_DEBUG=1" })}\n` +
287
290
  `Claude bridge: tool result delivery interrupted during ${reason}; ` +
288
291
  `delivered ${progress.deliveredCount}/${progress.expectedCount}, resolved ${progress.resolvedCount}/${progress.expectedCount}, ` +
289
292
  `waiting=${progress.waitingCount}, queued=${progress.queuedCount}, unmatched=${progress.unmatchedResultCount}` +
@@ -160,10 +160,12 @@ export function recordConnectorCallResult(
160
160
  isError: boolean,
161
161
  byteSize: number | undefined,
162
162
  ): boolean {
163
- const pending = queryCtx.connectorCallAudit.get(toolUseId);
163
+ const pending = queryCtx.childSideCalls.get(toolUseId);
164
164
  if (pending?.recorded) return false;
165
165
  const childSessionId = pending?.childSessionId ?? queryCtx.childSessionId;
166
- queryCtx.connectorCallAudit.set(toolUseId, { ...pending, name, childSessionId, recorded: true });
166
+ // Reached only for a child-executed connector result, so an entry this call
167
+ // never saw noted is a connector's.
168
+ queryCtx.childSideCalls.set(toolUseId, { ...pending, kind: pending?.kind ?? "connector", name, childSessionId, recorded: true });
167
169
  return appendConnectorCallAudit({
168
170
  name,
169
171
  toolUseId,
@@ -183,9 +185,11 @@ export function recordConnectorCallResult(
183
185
  */
184
186
  export function flushConnectorCallAudit(queryCtx: QueryContext, reason: ToolCallDrainCause): number {
185
187
  let appended = 0;
186
- for (const [toolUseId, state] of queryCtx.connectorCallAudit) {
187
- if (state.recorded) continue;
188
- queryCtx.connectorCallAudit.set(toolUseId, { ...state, recorded: true });
188
+ for (const [toolUseId, state] of queryCtx.childSideCalls) {
189
+ // Foreign MCP calls share the map but not the trail: its entries are
190
+ // `claude-bridge-connector-call` records naming a claude.ai connector.
191
+ if (state.recorded || state.kind !== "connector") continue;
192
+ queryCtx.childSideCalls.set(toolUseId, { ...state, recorded: true });
189
193
  const childSessionId = state.childSessionId ?? queryCtx.childSessionId;
190
194
  if (appendConnectorCallAudit({
191
195
  name: state.name,
@@ -244,8 +244,8 @@ export async function listAccountConnectors(deps: ListConnectorsDeps): Promise<C
244
244
  // fetch/proxy layer is free to put the request headers — and therefore the
245
245
  // bearer token — into the message it throws, and that message would otherwise
246
246
  // land in a reason string that callers log.
247
- const fail = (reason: string): ConnectorInventory =>
248
- ({ ok: false, complete: false, reason: redactSecret(reason, credentials.accessToken) });
247
+ const fail = (key: string, value: string | number, reason: string): ConnectorInventory =>
248
+ ({ ok: false, complete: false, reason: redactSecret(`${key}=${JSON.stringify(value)}\n${reason}`, credentials.accessToken) });
249
249
 
250
250
  let response: Response;
251
251
  try {
@@ -260,32 +260,32 @@ export async function listAccountConnectors(deps: ListConnectorsDeps): Promise<C
260
260
  signal,
261
261
  });
262
262
  } catch (error) {
263
- return fail(`connector list request failed: ${errorText(error)}`);
263
+ return fail("connector-request", "transport", `connector list request failed: ${errorText(error)}`);
264
264
  }
265
265
 
266
266
  let bodyText: string;
267
267
  try {
268
268
  bodyText = await response.text();
269
269
  } catch (error) {
270
- return fail(`connector list response unreadable: ${errorText(error)}`);
270
+ return fail("connector-response", "unreadable", `connector list response unreadable: ${errorText(error)}`);
271
271
  }
272
272
 
273
273
  if (!response.ok) {
274
- return fail(`connector list returned HTTP ${response.status}${apiErrorSuffix(bodyText)}`);
274
+ return fail("connector-http", response.status, `connector list returned HTTP ${response.status}${apiErrorSuffix(bodyText)}`);
275
275
  }
276
276
 
277
277
  let parsed: Json;
278
278
  try {
279
279
  parsed = JSON.parse(bodyText) as Json;
280
280
  } catch {
281
- return fail("connector list returned a non-JSON body");
281
+ return fail("connector-json", "invalid", "connector list returned a non-JSON body");
282
282
  }
283
283
 
284
284
  // A missing/!Array `results` is a protocol change, not an empty account. Treat
285
285
  // it as failure — reporting "no connectors" here would recreate exactly the
286
286
  // silent-wrong-answer failure this module exists to remove.
287
287
  if (!Array.isArray(parsed?.results)) {
288
- return fail("connector list response had no results array");
288
+ return fail("connector-results", "not-array", "connector list response had no results array");
289
289
  }
290
290
 
291
291
  const connectors: ConnectorEntry[] = [];
@@ -296,7 +296,7 @@ export async function listAccountConnectors(deps: ListConnectorsDeps): Promise<C
296
296
  // so silently keeping it would understate the inventory in a way the
297
297
  // caller could not detect. Fail instead.
298
298
  if (!name) {
299
- return fail("connector list contained an entry with no name");
299
+ return fail("connector-name", connectors.length, "connector list contained an entry with no name");
300
300
  }
301
301
  connectors.push({
302
302
  name,
package/src/connectors.ts CHANGED
@@ -517,6 +517,7 @@ function connectorWriteDenyOutput(toolName: string) {
517
517
  hookEventName: "PreToolUse" as const,
518
518
  permissionDecision: "deny" as const,
519
519
  permissionDecisionReason:
520
+ `connector-write-denied=${JSON.stringify(toolName)}\n` +
520
521
  `Connector write tool "${toolName}" is blocked in read-only connector mode. ` +
521
522
  `Connector writes must go through the host application's gated approval flow.`,
522
523
  },
@@ -3,7 +3,7 @@
3
3
  // generator and pushes events into the query's captured Pi stream.
4
4
 
5
5
  import { type Model } from "@earendil-works/pi-ai";
6
- import { type query } from "@anthropic-ai/claude-agent-sdk";
6
+ import { type AccountInfo, type query } from "@anthropic-ai/claude-agent-sdk";
7
7
  import {
8
8
  classifyClaudeFailure,
9
9
  rateLimitResetFromInfo,
@@ -88,6 +88,7 @@ export async function consumeQuery(
88
88
  model: Model<any>,
89
89
  bridgeConfig: Config,
90
90
  wasAborted: () => boolean,
91
+ recordBillingIdentity: (info: AccountInfo) => void,
91
92
  account?: ClaudeAccountRoute,
92
93
  router?: ClaudeAccountRouterV1,
93
94
  // Mirror of the held failure for the caller's .catch: the SDK iterator can
@@ -100,6 +101,7 @@ export async function consumeQuery(
100
101
  let capturedSessionId: string | undefined;
101
102
  let failure: ClaudeAttemptFailure | undefined;
102
103
  let accountProbe: Promise<void> | undefined;
104
+ let accountInfoProbe: Promise<AccountInfo> | undefined;
103
105
  const holdFailure = (next: ClaudeAttemptFailure | undefined): void => {
104
106
  failure = next;
105
107
  if (attemptFailureBox) attemptFailureBox.failure = next;
@@ -216,9 +218,22 @@ export async function consumeQuery(
216
218
  // from the teardown flush, which runs outside this function's scope.
217
219
  queryCtx.childSessionId = capturedSessionId;
218
220
  noteFastModeDisabledReason(message, bridgeConfig);
221
+ // Which login this child authenticated as is published for other
222
+ // extensions, for every child rather than only a routed one:
223
+ // an unrouted child is the common case and its identity is
224
+ // just as unknowable from outside the SDK. Nothing waits for
225
+ // it, so it stays off the turn's critical path. The call sits
226
+ // in an async IIFE so a synchronous throw arrives as a
227
+ // rejection the debug line below names.
228
+ if (!accountInfoProbe) {
229
+ accountInfoProbe = (async () => sdkQuery.accountInfo())();
230
+ void accountInfoProbe
231
+ .then((info) => recordBillingIdentity(info))
232
+ .catch((error) => debug("consumeQuery: billing identity probe rejected:", error));
233
+ }
219
234
  if (account && router && !accountProbe) {
220
235
  accountProbe = Promise.allSettled([
221
- sdkQuery.accountInfo().then((info) => router.recordIdentity(account.profileId, {
236
+ accountInfoProbe.then((info) => router.recordIdentity(account.profileId, {
222
237
  email: info.email,
223
238
  organization: info.organization,
224
239
  subscriptionType: info.subscriptionType,