@vanillagreen/pi-claude-bridge 4.0.0 → 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/src/config.ts CHANGED
@@ -44,7 +44,7 @@ export interface Config {
44
44
  modelEffortOverrides?: Record<string, BridgeEffortLevel>;
45
45
  /**
46
46
  * Verbatim override for the child's filesystem setting sources.
47
- * Defaults (see settingSourcesForQuery in connectors.ts): connectors
47
+ * Defaults (see settingSourcesForQuery in connectors.ts) : connectors
48
48
  * mode uses ["user"] only, so repo-controlled `.claude/settings.json`
49
49
  * (project/local scope) cannot inject `env`/`apiKeyHelper` into the
50
50
  * child. Listing "project"/"local" here reopens that surface — only do
@@ -95,17 +95,25 @@ function expandHome(input: string): string {
95
95
  return input;
96
96
  }
97
97
 
98
+ /** Root-anchored as `crates/core/src/harness/pi.rs::pi_root_is_absolute_for`
99
+ * means it, which `isAbsolute` is not: it calls a driveless `\root` absolute
100
+ * where the renderer does not, putting the two on different roots. Hoisted, so
101
+ * a circular import cannot reach it inside a temporal dead zone. */
102
+ function rootAnchored(path: string, windows: boolean): boolean { return windows ? /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/][^\\/]+)/.test(path) : path.startsWith("/"); }
103
+
98
104
  /**
99
- * The Pi agent config dir: `PI_CODING_AGENT_DIR` when set, else `~/.pi/agent`.
100
- * Every bridge default that used to hardcode `~/.pi/agent` routes through this
101
- * so a host app that owns the agent dir owns those paths too.
105
+ * The Pi agent config dir: `PI_CODING_AGENT_DIR` when it names a root-anchored
106
+ * path, else `~/.pi/agent`. Every bridge default routes through this function
107
+ * so a host app that owns the agent dir owns
108
+ * those paths too.
102
109
  */
103
110
  export function piUserDir(): string {
104
- return resolve(expandHome(process.env.PI_CODING_AGENT_DIR?.trim() || "~/.pi/agent"));
111
+ const override = expandHome(process.env.PI_CODING_AGENT_DIR?.trim() || "");
112
+ return resolve(rootAnchored(override, process.platform === "win32") ? override : expandHome("~/.pi/agent"));
105
113
  }
106
114
 
107
115
  /**
108
- * Isolated mode (`CLAUDE_BRIDGE_ISOLATED=1`): a host app embedding the bridge
116
+ * Isolated mode (`CLAUDE_BRIDGE_ISOLATED=1`) : a host app embedding the bridge
109
117
  * declares that nothing outside its explicitly configured dirs may be read.
110
118
  * Disables every cwd/home discovery fallback — all AGENTS.md discovery,
111
119
  * extension-manager settings, project `.pi/` settings + claude-bridge.json,
@@ -2,11 +2,9 @@
2
2
  //
3
3
  // A claude.ai connector tool runs INSIDE the child, on the child's own MCP
4
4
  // servers, and is deliberately never mirrored into the Pi stream (see
5
- // isChildExecutedTool). That is the honest behaviour — mirroring wrote
6
- // `Tool <name> not found` into the transcript for calls that had SUCCEEDED
7
- // (drovr#311 / memsira#320) — but it leaves the Pi session with no record that
8
- // the call happened at all, so "did it really look that up?" could only be
9
- // answered from the child's own transcript.
5
+ // isChildExecutedTool). Mirroring would write `Tool <name> not found` into the
6
+ // transcript for a successful call. Not mirroring leaves the Pi session with no
7
+ // record that the call happened, so the audit entry records it without dispatch.
10
8
  //
11
9
  // A pi `CustomEntry` closes that gap without reintroducing the bug: it is
12
10
  // persisted in the session file, is NOT a content block, and is documented as
@@ -91,17 +89,15 @@ let auditSink: ConnectorCallAuditSink | undefined;
91
89
  * Install (or clear, with `undefined`) a host sink for connector-call records.
92
90
  *
93
91
  * **The sink ADDS a destination, it never replaces `appendEntry`.** A host that
94
- * drives real `AgentSession`s gets the transcript-local entries for free, and a
95
- * replacing sink would take those away and reopen the very audit gap this
96
- * feature closes (memsira, 2026-07-28 — their `apps/sidecar/src/runtime.ts` is
97
- * session-backed, and 122 of their app-chat session files carry the bridge's
98
- * `claude-bridge-session` markers). A host with both a session and a sink has
92
+ * drives real `AgentSession`s gets transcript-local entries automatically. A
93
+ * replacing sink would remove those entries and make the session record
94
+ * incomplete. A host with both a session and a sink has
99
95
  * asked for both and gets both.
100
96
  *
101
97
  * It exists because the OTHER embedding shape gets nothing at all: drovr loads
102
98
  * the bundle through a throwaway resource loader over
103
99
  * `createAgentSessionServices` with no session, so `extensionApi` is undefined
104
- * and every record it appended went nowhere (drovr #317, measured live). The
100
+ * and every record it appended went nowhere (measured live). The
105
101
  * sink is the seam such a host can reach without one.
106
102
  *
107
103
  * A callback rather than another `Symbol.for` global on purpose: the bundle
@@ -164,10 +160,12 @@ export function recordConnectorCallResult(
164
160
  isError: boolean,
165
161
  byteSize: number | undefined,
166
162
  ): boolean {
167
- const pending = queryCtx.connectorCallAudit.get(toolUseId);
163
+ const pending = queryCtx.childSideCalls.get(toolUseId);
168
164
  if (pending?.recorded) return false;
169
165
  const childSessionId = pending?.childSessionId ?? queryCtx.childSessionId;
170
- 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 });
171
169
  return appendConnectorCallAudit({
172
170
  name,
173
171
  toolUseId,
@@ -187,9 +185,11 @@ export function recordConnectorCallResult(
187
185
  */
188
186
  export function flushConnectorCallAudit(queryCtx: QueryContext, reason: ToolCallDrainCause): number {
189
187
  let appended = 0;
190
- for (const [toolUseId, state] of queryCtx.connectorCallAudit) {
191
- if (state.recorded) continue;
192
- 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 });
193
193
  const childSessionId = state.childSessionId ?? queryCtx.childSessionId;
194
194
  if (appendConnectorCallAudit({
195
195
  name: state.name,
@@ -1,13 +1,12 @@
1
- // Cross-PROCESS cache of the connector inventory (kendex#870).
1
+ // Cross-PROCESS cache of the connector inventory.
2
2
  //
3
- // #868 primes the inventory at provider registration, but the fetch takes ~1.5s
3
+ // primes the inventory at provider registration, but the fetch takes ~1.5s
4
4
  // while the first query is built at ~0.5-0.8s, so turn 1 of a cold sidecar goes
5
- // out with no declarations and gets exactly the #832 bug it was meant to fix.
5
+ // out with no declarations and preserves the failure this cache prevents.
6
6
  //
7
7
  // An in-process cache cannot help the consumer that needs it most. drovr builds
8
8
  // a sidecar lazily on the first bridge round and, since their sidecars are
9
- // per-SESSION, that is a fresh process for every new chat — so their exposure is
10
- // once per chat, indefinitely, and every one of those is a cold process. The
9
+ // per-SESSION, each chat starts a separate cold process. The
11
10
  // cache therefore has to survive process boundaries.
12
11
  //
13
12
  // Keyed by credential scope, because that is what selects the account: the org
@@ -18,7 +17,7 @@
18
17
  // wrong-version cache returns undefined and the caller falls back to today's
19
18
  // behaviour — the same fail-open contract as the inventory call itself.
20
19
  //
21
- // The ON-DISK FORMAT HAS AN EXTERNAL READER (kendex#892). drovr quarantines this
20
+ // The ON-DISK FORMAT HAS AN EXTERNAL READER. drovr quarantines this
22
21
  // bundle to its sidecar process, so rather than calling `listAccountConnectors`
23
22
  // in-process it re-implements the reader half — path
24
23
  // `<piUserDir()>/connector-cache/<sha256(CLAUDE_CONFIG_DIR).hex[0..16]>.json`,
@@ -43,7 +42,7 @@ import type { ConnectorEntry } from "./connector-inventory.js";
43
42
  const CACHE_VERSION = 2;
44
43
  /** Long enough to be useful across a machine's lifetime, short enough that a
45
44
  * removed connector stops being declared without needing a manual purge. A
46
- * stale entry is not dangerous — a connector that no longer resolves simply
45
+ * stale entry is not dangerous — a connector that does not resolve simply
47
46
  * fails to connect, which is the fail-open path — so this is hygiene, not a
48
47
  * correctness boundary. */
49
48
  const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
@@ -5,7 +5,7 @@
5
5
  // nothing in the result distinguishes "these are the connectors" from "these are
6
6
  // the connectors the search happened to return this time". Downstream then stored
7
7
  // that lower bound as authoritative, so an account with Slack attached could
8
- // report an inventory without Slack and no failure signal (kendex#838).
8
+ // report an inventory without Slack and no failure signal.
9
9
  //
10
10
  // This module asks the account instead of the model. Verified live against a
11
11
  // personal claude_max org: the endpoint is POST (a GET returns 405) and each
@@ -18,12 +18,11 @@
18
18
  // installed. It says nothing about whether a given connector's MCP server has
19
19
  // finished attaching inside the `claude` child that is about to run a turn —
20
20
  // this is a plain HTTPS call and does not consult that process at all. The two
21
- // were previously conflated by accident: the ToolSearch probe could only report
22
- // what was already attached, so an inventory implied availability (wrongly, but
23
- // conservatively — it under-reported, which fails safe). They are now separately
21
+ // are separate: the ToolSearch probe can report only what is attached, so an
22
+ // inventory must not imply availability. They are
24
23
  // observable and can legitimately disagree: a correct `complete: true` inventory
25
24
  // can name Slack while `mcp__claude_ai_Slack__*` is not yet callable in this
26
- // process (kendex#832). Treat an inventory as NECESSARY BUT NOT SUFFICIENT for
25
+ // process. Treat an inventory as NECESSARY BUT NOT SUFFICIENT for
27
26
  // availability and keep an attach-time check on the call path; do not derive
28
27
  // "can I call this tool right now" from this result.
29
28
  //
@@ -47,8 +46,7 @@ export type ConnectorEntry = {
47
46
  /**
48
47
  * Account-side install state. `"connected"` marks the connectors the CLI
49
48
  * actually attempts; everything else it never gives a `Starting connection`
50
- * line at all. Verified live 2026-07-26: 7 `connected` / 20 `unknown` on the
51
- * app account, and the CLI connected exactly those 7.
49
+ * line at all. The CLI attempts exactly the entries marked `connected`.
52
50
  */
53
51
  installState?: string;
54
52
  description?: string;
@@ -246,8 +244,8 @@ export async function listAccountConnectors(deps: ListConnectorsDeps): Promise<C
246
244
  // fetch/proxy layer is free to put the request headers — and therefore the
247
245
  // bearer token — into the message it throws, and that message would otherwise
248
246
  // land in a reason string that callers log.
249
- const fail = (reason: string): ConnectorInventory =>
250
- ({ 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) });
251
249
 
252
250
  let response: Response;
253
251
  try {
@@ -262,32 +260,32 @@ export async function listAccountConnectors(deps: ListConnectorsDeps): Promise<C
262
260
  signal,
263
261
  });
264
262
  } catch (error) {
265
- return fail(`connector list request failed: ${errorText(error)}`);
263
+ return fail("connector-request", "transport", `connector list request failed: ${errorText(error)}`);
266
264
  }
267
265
 
268
266
  let bodyText: string;
269
267
  try {
270
268
  bodyText = await response.text();
271
269
  } catch (error) {
272
- return fail(`connector list response unreadable: ${errorText(error)}`);
270
+ return fail("connector-response", "unreadable", `connector list response unreadable: ${errorText(error)}`);
273
271
  }
274
272
 
275
273
  if (!response.ok) {
276
- 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)}`);
277
275
  }
278
276
 
279
277
  let parsed: Json;
280
278
  try {
281
279
  parsed = JSON.parse(bodyText) as Json;
282
280
  } catch {
283
- return fail("connector list returned a non-JSON body");
281
+ return fail("connector-json", "invalid", "connector list returned a non-JSON body");
284
282
  }
285
283
 
286
284
  // A missing/!Array `results` is a protocol change, not an empty account. Treat
287
285
  // it as failure — reporting "no connectors" here would recreate exactly the
288
286
  // silent-wrong-answer failure this module exists to remove.
289
287
  if (!Array.isArray(parsed?.results)) {
290
- return fail("connector list response had no results array");
288
+ return fail("connector-results", "not-array", "connector list response had no results array");
291
289
  }
292
290
 
293
291
  const connectors: ConnectorEntry[] = [];
@@ -298,7 +296,7 @@ export async function listAccountConnectors(deps: ListConnectorsDeps): Promise<C
298
296
  // so silently keeping it would understate the inventory in a way the
299
297
  // caller could not detect. Fail instead.
300
298
  if (!name) {
301
- 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");
302
300
  }
303
301
  connectors.push({
304
302
  name,
@@ -1,5 +1,5 @@
1
1
  // Connector prime/snapshot host: the per-credential-scope inventory cache the
2
- // query path reads synchronously (kendex#832/#870). Extracted from index.ts —
2
+ // query path reads synchronously. Extracted from index.ts —
3
3
  // this is process-lifetime runtime state, not provider streaming logic.
4
4
  //
5
5
  // Connector declarations for the query path, cached per credential scope. The
@@ -32,15 +32,15 @@ export function readCredentialFile(path: string): string | undefined {
32
32
  const connectorServerCache = new Map<string, Record<string, unknown>>();
33
33
  const connectorServerPending = new Set<string>();
34
34
  // Epoch ms of the last FAILED inventory attempt per scope, so a persistently
35
- // failing account cools down instead of issuing one new HTTPS request per turn
36
- // (VST-14). Missing credentials are exempt: that check is a local file read
35
+ // failing account cools down instead of issuing one HTTPS request per turn
36
+ // Missing credentials are exempt: that check is a local file read
37
37
  // with no request to bound, and a just-completed `claude login` must take
38
38
  // effect on the next turn.
39
39
  const connectorServerFailureAt = new Map<string, number>();
40
40
 
41
41
  // Deadline on the inventory round trip. Without one, a hung claude.ai request
42
42
  // held the pending flag forever — same budget as the account-host probe's
43
- // ACCOUNT_PROBE_DEADLINE_MS (VST-14).
43
+ // ACCOUNT_PROBE_DEADLINE_MS.
44
44
  const CONNECTOR_PRIME_TIMEOUT_MS = 10_000;
45
45
  const CONNECTOR_PRIME_FAILURE_COOLDOWN_MS = 60_000;
46
46
 
@@ -81,12 +81,12 @@ export function connectorCredentialEnv(claudeConfigDir: string | undefined = pro
81
81
  //
82
82
  // FAILS OPEN throughout: no credentials, a failed inventory, or a thrown call
83
83
  // all resolve to "declare nothing" rather than breaking the turn. Failures are
84
- // NOT cached — a transient blip at registration used to pin `{}` for the
84
+ // NOT cached. Caching a transient registration failure would pin `{}` for the
85
85
  // process lifetime, keeping every later turn undeclared and the disk-cache
86
86
  // fallback unreachable. Leaving the key unset makes the next snapshot retry;
87
87
  // the pending set dedupes concurrent fetches, and a failed inventory attempt
88
88
  // stamps a per-scope cooldown so a persistently failing account backs off
89
- // instead of re-priming on every turn (VST-14).
89
+ // instead of re-priming on every turn.
90
90
  export function primeConnectorServers(claudeConfigDir?: string, overrides: PrimeConnectorOverrides = {}): void {
91
91
  const key = connectorScopeKey(claudeConfigDir);
92
92
  if (connectorServerCache.has(key) || connectorServerPending.has(key)) return;
@@ -124,7 +124,7 @@ export function primeConnectorServers(claudeConfigDir?: string, overrides: Prime
124
124
  connectorServerFailureAt.delete(key);
125
125
  // Persist so the NEXT cold process has this synchronously. Priming always
126
126
  // loses the race against turn 1 in its own process; a cache written by an
127
- // earlier run is the only thing turn 1 can read in time (kendex#870).
127
+ // earlier run is the only thing turn 1 can read in time.
128
128
  if (writeCachedConnectors(inventory.connectors, key)) {
129
129
  debug(`connectors: cached ${inventory.connectors.length} entries`);
130
130
  }
@@ -148,7 +148,7 @@ export function connectorServersSnapshot(claudeConfigDir?: string): Record<strin
148
148
  primeConnectorServers(claudeConfigDir);
149
149
  // Fall back to the previous run's inventory, read synchronously. This is the
150
150
  // only thing that can populate turn 1 of a cold process, because priming
151
- // cannot finish before the first query is built (kendex#870).
151
+ // cannot finish before the first query is built.
152
152
  const cached = readCachedConnectors(key);
153
153
  if (!cached) return {};
154
154
  const servers = connectorMcpServers({ ok: true, complete: true, connectors: cached });
package/src/connectors.ts CHANGED
@@ -57,7 +57,7 @@ export function connectorsEnabledFor(config?: Config): boolean {
57
57
  // isolation (no sources), which drops the connectors even with
58
58
  // ENABLE_CLAUDEAI_MCP_SERVERS=1. So connectors mode must pass SOME source list.
59
59
  //
60
- // It must be `["user"]` and nothing more (kendex#990). Connector state lives in
60
+ // It must be `["user"]` and nothing more. Connector state lives in
61
61
  // USER scope — the account's config dir (CLAUDE_CONFIG_DIR for managed router
62
62
  // profiles) — so user scope is sufficient for connectors to surface. Claude
63
63
  // Code settings files can also carry an `env` map and `apiKeyHelper`; including
@@ -98,7 +98,7 @@ export const CLAUDE_AI_CONNECTOR_TOOL_PATTERNS = [
98
98
  "mcp__claude_ai_Atlassian__*",
99
99
  ];
100
100
 
101
- // --- The SDK's two-name trap for built-in tools (kendex#1007, kendex#1011) ---
101
+ // --- The SDK's two-name trap for built-in tools ---
102
102
  //
103
103
  // The CLI gives some built-ins TWO spellings, and which one you see depends on
104
104
  // which SURFACE the name crosses:
@@ -118,9 +118,9 @@ export const CLAUDE_AI_CONNECTOR_TOOL_PATTERNS = [
118
118
  // This map declares each alias pair ONCE; every delivered-side membership set
119
119
  // derives its spellings from it instead of hand-copying names. Both prior
120
120
  // instances of the trap were exactly that hand-copy: CHILD_INTERNAL_TOOLS held
121
- // request-side spellings that no stream name ever matched (kendex#1007), and
121
+ // request-side spellings that no stream name ever matched, and
122
122
  // the connectors allowlist hook DENIED the two discovery tools it exists to
123
- // permit (kendex#1011). Delivered-side sets accept BOTH spellings, so a CLI
123
+ // permit. Delivered-side sets accept BOTH spellings, so a CLI
124
124
  // version that drops the aliasing cannot reintroduce the bug in either
125
125
  // direction.
126
126
  const SDK_TOOL_ALIASES: Record<string, string> = {
@@ -144,14 +144,18 @@ function deliveredSpellings(name: string): string[] {
144
144
  // REQUEST-side spellings: this list feeds the SDK option surface (the
145
145
  // disallowedTools filter in toolIsolationForQuery) and is the exported public
146
146
  // name. Delivered-side checks use CONNECTOR_DISCOVERY_TOOL_NAMES below.
147
- export const CONNECTOR_DISCOVERY_TOOLS = ["ToolSearch", "ListMcpResources", "ReadMcpResource"];
147
+ const MCP_RESOURCE_TOOLS = ["ListMcpResources", "ReadMcpResource"];
148
+ export const CONNECTOR_DISCOVERY_TOOLS = ["ToolSearch", ...MCP_RESOURCE_TOOLS];
148
149
 
149
- // Delivered-side membership set for the discovery tools: both spellings of
150
- // each entry, derived from SDK_TOOL_ALIASES. A PreToolUse hook's `tool_name`
151
- // carries the canonical spelling (`ListMcpResourcesTool`), so testing the
152
- // request-side list directly made the fail-closed allowlist hook DENY the two
153
- // resource tools it exists to permit (kendex#1011).
150
+ // Delivered-side membership sets carry both spellings, derived from the one
151
+ // alias declaration above. The resource-only set keeps those audit calls
152
+ // dispatchable without making ToolSearch a Pi call.
154
153
  const CONNECTOR_DISCOVERY_TOOL_NAMES = new Set(CONNECTOR_DISCOVERY_TOOLS.flatMap(deliveredSpellings));
154
+ const MCP_RESOURCE_TOOL_NAMES = new Set(MCP_RESOURCE_TOOLS.flatMap(deliveredSpellings));
155
+
156
+ export function isMcpResourceTool(name: string): boolean {
157
+ return MCP_RESOURCE_TOOL_NAMES.has(name);
158
+ }
155
159
 
156
160
  // --- Connector WRITE tool control (read-inline / write-by-approval) ---
157
161
  //
@@ -243,12 +247,12 @@ function connectorNameWords(segment: string): string[] {
243
247
  // exact tool id (the CLI matcher only supports exact ids or a whole-server glob).
244
248
  //
245
249
  // PUBLIC CONTRACT — this list and `isConnectorWriteTool` have downstream
246
- // dependents that gate real user-facing approvals on them (kendex#892):
250
+ // dependents that gate real user-facing approvals on them:
247
251
  //
248
252
  // memsira routes connector writes through its own gated approval flow
249
253
  // drovr keeps its chat sidecar permanently write-`deny` and runs an
250
254
  // approved write as a separate one-shot `claude -p` scoped by
251
- // `--allowedTools` to exactly one connector tool (drovr#288)
255
+ // `--allowedTools` to exactly one connector tool
252
256
  //
253
257
  // Both pin the actions they expose against this classification, because "the
254
258
  // sidecar structurally cannot do this itself" is THIS module's claim, not
@@ -329,23 +333,21 @@ export function isConnectorTool(name: string | undefined): boolean {
329
333
  // `content_block_start` / assistant-message blocks, the exact fields
330
334
  // processStreamEvent/processAssistantMessage read. Stream names are a
331
335
  // DELIVERED surface (see SDK_TOOL_ALIASES): the two MCP-resource built-ins'
332
- // request-side spellings used to sit in this set and never matched anything
333
- // (kendex#1007). If an aliased name is ever added here, derive its spellings
334
- // via deliveredSpellings rather than hand-copying them.
336
+ // request-side spellings do not belong in this set because they match nothing.
337
+ // Derive every aliased name via deliveredSpellings rather than hand-copying it.
335
338
  //
336
- // The MCP-resource tools are now EXCLUDED deliberately, under BOTH spellings:
339
+ // The MCP-resource tools are EXCLUDED under BOTH spellings:
337
340
  // a resource read is a real account-surface access, and both consumer hosts
338
341
  // audit it through the Pi mirror (an out-of-process sidecar has no view of the
339
342
  // bridge's own connector-call entries or the child transcript), so it stays
340
- // mirrored into Pi. Do not re-add either spelling without revisiting that
341
- // decision in kendex#1007. The allowlist hook is what lets those calls run at
342
- // all — see isAllowlistedConnectorSessionTool (kendex#1011).
343
+ // mirrored into Pi. Do not add either spelling to this set. The allowlist hook
344
+ // is what lets those calls run; see isAllowlistedConnectorSessionTool.
343
345
  const CHILD_INTERNAL_TOOLS = new Set(["ToolSearch", "ScheduleWakeup"]);
344
346
 
345
347
  /**
346
348
  * True for a Claude Code built-in meta-tool the child resolves in-process.
347
349
  *
348
- * Mirroring one into the Pi stream (kendex#980) made Pi's agent loop dispatch a
350
+ * Mirroring one into the Pi stream would make Pi's agent loop dispatch a
349
351
  * tool it does not have and deliver an error result for an id no MCP handler
350
352
  * ever claimed. The result queued in `pendingResults` until the reaper dropped
351
353
  * it — one "dropped 1 tool result(s) whose handler never matched (ToolSearch)"
@@ -375,13 +377,13 @@ export function isChildInternalTool(name: string | undefined): boolean {
375
377
  * miss, and write a synthetic `Tool <name> not found` error result into the
376
378
  * transcript — while the child went on and executed the real call. The Pi
377
379
  * transcript then RECORDED A FAILURE FOR A CALL THAT SUCCEEDED, next to an
378
- * answer built from the real payload, so the model's correct answer read as
379
- * a fabrication (drovr#311, memsira#320). The false result is also projected
380
+ * answer built from the real payload, so the model's correct answer appears
381
+ * fabricated. The false result is also projected
380
382
  * back into the child's session on a rebuild (`syncSharedSession`), which is
381
383
  * how a lie in a mirror becomes a lie in the conversation of record.
382
384
  *
383
385
  * 2. Claude Code's own in-process meta-tools (`isChildInternalTool`), which the
384
- * child resolves without any dispatcher at all (kendex#980).
386
+ * child resolves without any dispatcher at all.
385
387
  *
386
388
  * Takes the RAW SDK tool name, before `mapToolName` — child-executed names have
387
389
  * no Pi-side counterpart, so mapping them is meaningless. Accepts a missing
@@ -487,7 +489,7 @@ export function connectorWriteDenyHook(): HookCallback {
487
489
  // the tool call proceed (fail OPEN) — so any exception in this body
488
490
  // must convert to a deny, never an allow. Today's body is pure string
489
491
  // checks on schema-validated input; the catch pins that invariant for
490
- // whatever gets added here later.
492
+ // subsequent changes to this body.
491
493
  try {
492
494
  if (input.hook_event_name !== "PreToolUse") return { continue: true };
493
495
  // A non-string tool name cannot be classified, and this hook fails
@@ -507,7 +509,7 @@ export function connectorWriteDenyHook(): HookCallback {
507
509
  // stay PRODUCT-NEUTRAL: this is shared source and every consuming app shows it.
508
510
  // Naming one host told a different app's model to use a product it has never
509
511
  // heard of, which is confusing at exactly the moment someone is debugging a
510
- // refused write (kendex#892). Each host describes its own approval flow in its
512
+ // refused write. Each host describes its own approval flow in its
511
513
  // own prompt; this string only has to say that one exists.
512
514
  function connectorWriteDenyOutput(toolName: string) {
513
515
  return {
@@ -515,6 +517,7 @@ function connectorWriteDenyOutput(toolName: string) {
515
517
  hookEventName: "PreToolUse" as const,
516
518
  permissionDecision: "deny" as const,
517
519
  permissionDecisionReason:
520
+ `connector-write-denied=${JSON.stringify(toolName)}\n` +
518
521
  `Connector write tool "${toolName}" is blocked in read-only connector mode. ` +
519
522
  `Connector writes must go through the host application's gated approval flow.`,
520
523
  },
@@ -533,8 +536,8 @@ function connectorWriteDenyOutput(toolName: string) {
533
536
  //
534
537
  // This is a DELIVERED-side check — `name` is a hook's `input.tool_name`, which
535
538
  // carries the canonical spelling — so membership is tested against
536
- // CONNECTOR_DISCOVERY_TOOL_NAMES (both spellings), not the request-side list
537
- // (kendex#1011). That also carries kendex#1007's mirroring decision: the
539
+ // CONNECTOR_DISCOVERY_TOOL_NAMES (both spellings), not the request-side list.
540
+ // The allowlist hook also carries the mirroring rule: the
538
541
  // MCP-resource tools are deliberately NOT child-internal so every resource
539
542
  // read mirrors into Pi as the consumers' audit surface — a mirror that can
540
543
  // only exist if this allowlist lets the call execute. Denying the canonical
@@ -669,14 +672,12 @@ export function toolIsolationForQuery(connectorsEnabled: boolean, writeMode: Con
669
672
  /**
670
673
  * Explicit `mcpServers` declarations for the account's CONNECTED connectors.
671
674
  *
672
- * Why this exists (kendex#832): claude.ai connectors load async and non-blocking,
675
+ * Why this exists: claude.ai connectors load async and non-blocking,
673
676
  * and the turn-1 tool manifest is built at +410-665ms — roughly 300ms BEFORE the
674
677
  * CLI has even fetched the connector list. The model therefore composes its first
675
678
  * answer against a manifest containing no connectors and says it has no access,
676
- * while the connector attaches ~1s later and is never asked. Measured end to end
677
- * on 40 cold sidecars (memsira, 2026-07-26): a connector tool call happened in
678
- * 7/20 baseline runs versus 20/20 with the declaration, and "I don't have access"
679
- * went 13/20 → 0/20, one-sided Fisher exact p = 6.4e-6. Confirmed at seven
679
+ * while the connector attaches later and is never asked. The declaration must
680
+ * be available before the first query. Confirmed at seven
680
681
  * declarations over a further 30 runs: 5/10 → 10/10 calls, 5/10 → 0/10 denials.
681
682
  *
682
683
  * It is also FASTER, which is the opposite of what the startup barrier suggests.
@@ -736,9 +737,8 @@ export function connectorMcpServers(inventory: ConnectorInventory): Record<strin
736
737
 
737
738
  /**
738
739
  * `CLAUDE_BRIDGE_CONNECTOR_DECLARE=off` (or `0`/`false`/`no`) disables explicit
739
- * connector declarations while leaving connectors themselves enabled. Falls back
740
- * to the pre-#832 behaviour: connectors still load, they just race the turn-1
741
- * manifest again.
740
+ * connector declarations while leaving connectors themselves enabled. Without
741
+ * declarations, connector loading races the turn-1 manifest.
742
742
  */
743
743
  export function connectorDeclarationsDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
744
744
  const v = (env.CLAUDE_BRIDGE_CONNECTOR_DECLARE ?? "").trim().toLowerCase();
@@ -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;
@@ -111,7 +113,7 @@ export async function consumeQuery(
111
113
  if (account) {
112
114
  // Thunk, not a value: this runs once per SDK message — including one
113
115
  // stream_event per streamed token — and debug() only evaluates function
114
- // args after its DEBUG early return (VST-15).
116
+ // args after its DEBUG early return.
115
117
  debug("consumeQuery: managed message", () => JSON.stringify({
116
118
  type: message.type,
117
119
  subtype: (message as any).subtype,
@@ -203,8 +205,8 @@ export async function consumeQuery(
203
205
  }
204
206
  // Other non-success subtypes (error_max_turns,
205
207
  // error_during_execution) surface at completion via the held
206
- // failure — an explicit error event where these turns previously
207
- // ended silently. Session persistence and deferred replay still run.
208
+ // failure. These turns require an explicit error event instead of
209
+ // silent completion. Session persistence and deferred replay still run.
208
210
  }
209
211
  break;
210
212
  case "system":
@@ -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,
package/src/convert.ts CHANGED
@@ -28,7 +28,7 @@ export function mapPiToolNameToSdk(name: string, customToolNameToSdk?: Map<strin
28
28
  // `McpClaudeAiSlackSlackSearchChannels`) that appeared in the child's
29
29
  // projected history, so the model imitated it on the next turn and got a
30
30
  // real `Tool ... not found` from the MCP dispatcher before retrying the
31
- // canonical name — one wasted round-trip per affected call (memsira#320).
31
+ // canonical name — one wasted round-trip per affected call.
32
32
  //
33
33
  // Connector names stopped reaching this function at all once they stopped
34
34
  // being mirrored as Pi tool calls (isChildExecutedTool), so this is the
package/src/debug.ts CHANGED
@@ -50,7 +50,7 @@ export function debug(...args: unknown[]) {
50
50
  if (a instanceof Error) return `${a.name}: ${a.message}${a.stack ? "\n" + a.stack : ""}`;
51
51
  // A function argument is a lazy payload: hot-path call sites (per-token
52
52
  // stream events) pass a thunk so the expensive formatting only runs when
53
- // DEBUG is on — fmt is only reached past the early return (VST-15).
53
+ // DEBUG is on — fmt is only reached past the early return.
54
54
  if (typeof a === "function") return fmt((a as () => unknown)());
55
55
  return JSON.stringify(a);
56
56
  };
@@ -121,7 +121,7 @@ export function makeCliDebugOptions(tag: string): { debug?: boolean; debugFile?:
121
121
  /** Diagnostic dump — for "should never happen" paths. Gated on the same
122
122
  * CLAUDE_BRIDGE_DEBUG flag as debug(): the entries carry session metadata
123
123
  * and land in a log outside any host app's retention/cleanup boundary, so
124
- * a host that has not opted into debugging must get no disk write (VST-15). */
124
+ * a host that has not opted into debugging must get no disk write. */
125
125
  export function diagDump(label: string, data: Record<string, unknown>) {
126
126
  if (!DEBUG) return;
127
127
  try {