@vanillagreen/pi-claude-bridge 1.9.0 → 2.0.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.
@@ -0,0 +1,203 @@
1
+ // Audit trail for connector calls the `claude` child executes itself.
2
+ //
3
+ // A claude.ai connector tool runs INSIDE the child, on the child's own MCP
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.
10
+ //
11
+ // A pi `CustomEntry` closes that gap without reintroducing the bug: it is
12
+ // persisted in the session file, is NOT a content block, and is documented as
13
+ // "ignored by buildSessionContext", so Pi's agent loop can never dispatch it and
14
+ // `convertPiMessages` (which reads messages, not entries) can never project it
15
+ // back into the child's session. `CustomMessageEntry` is the sibling type that
16
+ // DOES enter context — using it here would recreate the whole problem.
17
+ //
18
+ // The payload is never recorded. A connector result is live account data (mail,
19
+ // messages, documents); the audit answers whether a call happened and what came
20
+ // back, not what it said.
21
+
22
+ import { extensionApi } from "./bridge-state.js";
23
+ import { debug } from "./debug.js";
24
+ import type { QueryContext, ToolCallDrainCause } from "./query-state.js";
25
+
26
+ export const CONNECTOR_CALL_CUSTOM_TYPE = "claude-bridge-connector-call";
27
+
28
+ /**
29
+ * What the bridge observed of a connector call.
30
+ *
31
+ * `unobserved` is the load-bearing one: the call was issued and the query ended
32
+ * before its result came back. Recording nothing for it would leave an answer in
33
+ * the transcript with no trace of the call behind it — indistinguishable from a
34
+ * turn where no call was ever made. Same reasoning as `interruptedToolCallResult`
35
+ * for Pi-side tools: a call that did not complete says so.
36
+ */
37
+ export type ConnectorCallOutcome = "ok" | "error" | "unobserved";
38
+
39
+ export interface ConnectorCallAuditData {
40
+ /** Raw connector tool name as the child invoked it (`mcp__claude_ai_<Server>__<tool>`). */
41
+ name: string;
42
+ /** The child's own `tool_use` id — the join key to its transcript. */
43
+ toolUseId: string;
44
+ outcome: ConnectorCallOutcome;
45
+ /** UTF-8 byte size of the observed result payload. Absent when the result was
46
+ * never observed, or could not be measured — never a confident 0 for
47
+ * something that was not sized. */
48
+ byteSize?: number;
49
+ /** Claude Code session that executed the call. Absent when the SDK never
50
+ * reported one for this query. */
51
+ childSessionId?: string;
52
+ /** Why an `unobserved` call ended. Absent for observed ones. */
53
+ reason?: ToolCallDrainCause;
54
+ }
55
+
56
+ /**
57
+ * Approximate UTF-8 byte size of a child tool result's payload.
58
+ *
59
+ * A string payload is measured directly; any other shape is measured as its JSON
60
+ * serialization, so a structured or image result reports the size it really
61
+ * carried. (The debug line this replaces reported `content.length` for an array
62
+ * payload — a BLOCK count wearing a byte size's name.)
63
+ *
64
+ * Returns undefined when there is nothing to measure or the payload cannot be
65
+ * serialized, so the caller omits the field rather than recording a 0 it did not
66
+ * measure. The payload itself is never returned or logged.
67
+ */
68
+ export function connectorResultByteSize(content: unknown): number | undefined {
69
+ if (content === undefined || content === null) return undefined;
70
+ if (typeof content === "string") return Buffer.byteLength(content, "utf8");
71
+ try {
72
+ const json = JSON.stringify(content);
73
+ return typeof json === "string" ? Buffer.byteLength(json, "utf8") : undefined;
74
+ } catch {
75
+ return undefined;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * An ADDITIONAL destination for connector-call records, for a host that embeds
81
+ * the bridge with no pi session to append to.
82
+ *
83
+ * Never throws is the contract on OUR side; a sink that throws anyway is caught
84
+ * and dropped, because an audit record must not be able to fail a turn.
85
+ */
86
+ export type ConnectorCallAuditSink = (data: ConnectorCallAuditData) => void;
87
+
88
+ let auditSink: ConnectorCallAuditSink | undefined;
89
+
90
+ /**
91
+ * Install (or clear, with `undefined`) a host sink for connector-call records.
92
+ *
93
+ * **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
99
+ * asked for both and gets both.
100
+ *
101
+ * It exists because the OTHER embedding shape gets nothing at all: drovr loads
102
+ * the bundle through a throwaway resource loader over
103
+ * `createAgentSessionServices` with no session, so `extensionApi` is undefined
104
+ * and every record it appended went nowhere (drovr #317, measured live). The
105
+ * sink is the seam such a host can reach without one.
106
+ *
107
+ * A callback rather than another `Symbol.for` global on purpose: the bundle
108
+ * already has one (`claude-bridge:activeStreamSimple`) and hosts document that
109
+ * coupling as a re-vendor hazard, so a second would be the wrong direction.
110
+ *
111
+ * Process-global, matching `setExtensionApi`: one bundle instance serves one
112
+ * host. A host that runs several conversations in ONE process must therefore
113
+ * route by `childSessionId` itself, or embed per conversation as drovr does.
114
+ */
115
+ export function setConnectorCallAuditSink(sink: ConnectorCallAuditSink | undefined): void {
116
+ auditSink = sink;
117
+ }
118
+
119
+ /**
120
+ * Append one audit entry to every destination the host installed. Returns
121
+ * whether it reached AT LEAST ONE of them, so a caller can log the truth rather
122
+ * than assume — both are absent whenever the bridge runs outside a pi session
123
+ * and outside a sink-installing host (tests, the connector-inventory entry
124
+ * point).
125
+ *
126
+ * The two destinations are independent: one throwing must not cost the other
127
+ * its record. Never throws — an audit record must not be able to fail a turn.
128
+ */
129
+ export function appendConnectorCallAudit(data: ConnectorCallAuditData): boolean {
130
+ let delivered = false;
131
+ if (extensionApi) {
132
+ try {
133
+ extensionApi.appendEntry(CONNECTOR_CALL_CUSTOM_TYPE, data);
134
+ delivered = true;
135
+ } catch (error) {
136
+ debug("appendConnectorCallAudit failed:", error);
137
+ }
138
+ }
139
+ if (auditSink) {
140
+ try {
141
+ // A COPY, because the sink is host code and `appendEntry` above holds a
142
+ // reference to the same object: a sink that mutated the record would be
143
+ // editing what the session already recorded.
144
+ auditSink({ ...data });
145
+ delivered = true;
146
+ } catch (error) {
147
+ debug("connector call audit sink failed:", error);
148
+ }
149
+ }
150
+ return delivered;
151
+ }
152
+
153
+ /**
154
+ * Record the observed result of a child-executed connector call, once.
155
+ *
156
+ * Keyed on the tool_use id rather than on the call site: the SDK can re-yield a
157
+ * `user` message, and an audit trail that counts one call twice is worse than
158
+ * one that counts it not at all. Returns whether an entry was appended.
159
+ */
160
+ export function recordConnectorCallResult(
161
+ queryCtx: QueryContext,
162
+ toolUseId: string,
163
+ name: string,
164
+ isError: boolean,
165
+ byteSize: number | undefined,
166
+ ): boolean {
167
+ const pending = queryCtx.connectorCallAudit.get(toolUseId);
168
+ if (pending?.recorded) return false;
169
+ const childSessionId = pending?.childSessionId ?? queryCtx.childSessionId;
170
+ queryCtx.connectorCallAudit.set(toolUseId, { ...pending, name, childSessionId, recorded: true });
171
+ return appendConnectorCallAudit({
172
+ name,
173
+ toolUseId,
174
+ outcome: isError ? "error" : "ok",
175
+ ...(byteSize !== undefined ? { byteSize } : {}),
176
+ ...(childSessionId ? { childSessionId } : {}),
177
+ });
178
+ }
179
+
180
+ /**
181
+ * Record every connector call this query issued whose result never came back,
182
+ * naming the cause. Runs at query teardown, beside the Pi-side tool drain, and is
183
+ * idempotent — a call already recorded is skipped, and one recorded here is
184
+ * marked so a late arrival cannot record it again.
185
+ *
186
+ * Returns how many entries were appended.
187
+ */
188
+ export function flushConnectorCallAudit(queryCtx: QueryContext, reason: ToolCallDrainCause): number {
189
+ let appended = 0;
190
+ for (const [toolUseId, state] of queryCtx.connectorCallAudit) {
191
+ if (state.recorded) continue;
192
+ queryCtx.connectorCallAudit.set(toolUseId, { ...state, recorded: true });
193
+ const childSessionId = state.childSessionId ?? queryCtx.childSessionId;
194
+ if (appendConnectorCallAudit({
195
+ name: state.name,
196
+ toolUseId,
197
+ outcome: "unobserved",
198
+ reason,
199
+ ...(childSessionId ? { childSessionId } : {}),
200
+ })) appended++;
201
+ }
202
+ return appended;
203
+ }
@@ -0,0 +1,118 @@
1
+ // Cross-PROCESS cache of the connector inventory (vstack#870).
2
+ //
3
+ // #868 primes the inventory at provider registration, but the fetch takes ~1.5s
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.
6
+ //
7
+ // An in-process cache cannot help the consumer that needs it most. drovr builds
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
11
+ // cache therefore has to survive process boundaries.
12
+ //
13
+ // Keyed by credential scope, because that is what selects the account: the org
14
+ // UUID in the inventory request is ignored and only the credential decides whose
15
+ // connectors come back. Two accounts on one host must not share a cache entry.
16
+ //
17
+ // Everything here is best-effort. A missing, unreadable, corrupt, stale, or
18
+ // wrong-version cache returns undefined and the caller falls back to today's
19
+ // behaviour — the same fail-open contract as the inventory call itself.
20
+ //
21
+ // The ON-DISK FORMAT HAS AN EXTERNAL READER (vstack#892). drovr quarantines this
22
+ // bundle to its sidecar process, so rather than calling `listAccountConnectors`
23
+ // in-process it re-implements the reader half — path
24
+ // `<piUserDir()>/connector-cache/<sha256(CLAUDE_CONFIG_DIR).hex[0..16]>.json`,
25
+ // payload `{version, scope, savedAt, connectors}`, 7-day max age — as the
26
+ // "is this connector installed" half of its write gate.
27
+ //
28
+ // That coupling fails OPEN on drift by design, so a format change degrades them
29
+ // from two gates to one rather than breaking them. It is still worth making the
30
+ // change knowingly: bump CACHE_VERSION so their staleness check rejects rather
31
+ // than misreads, and say so in the changelog. `unit-connector-cache.mjs` pins
32
+ // the path shape and payload keys.
33
+ import { createHash } from "node:crypto";
34
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
35
+ import { dirname, join } from "node:path";
36
+ import { piUserDir } from "./config.js";
37
+ import type { ConnectorEntry } from "./connector-inventory.js";
38
+
39
+ const CACHE_VERSION = 1;
40
+ /** Long enough to be useful across a machine's lifetime, short enough that a
41
+ * removed connector stops being declared without needing a manual purge. A
42
+ * stale entry is not dangerous — a connector that no longer resolves simply
43
+ * fails to connect, which is the fail-open path — so this is hygiene, not a
44
+ * correctness boundary. */
45
+ const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
46
+
47
+ export function connectorCacheScopeKey(env: NodeJS.ProcessEnv = process.env): string {
48
+ return env.CLAUDE_CONFIG_DIR?.trim() || "<default>";
49
+ }
50
+
51
+ /**
52
+ * Our own state directory, not the Claude config dir. The credential directory
53
+ * belongs to the CLI; the scope is encoded in the filename instead so we never
54
+ * write into someone else's tree. Hashed rather than escaped because a config
55
+ * dir is an arbitrary absolute path.
56
+ */
57
+ export function connectorCachePath(scopeKey: string = connectorCacheScopeKey()): string {
58
+ const digest = createHash("sha256").update(scopeKey).digest("hex").slice(0, 16);
59
+ return join(piUserDir(), "connector-cache", `${digest}.json`);
60
+ }
61
+
62
+ /**
63
+ * Synchronous by design. The query path has no await boundary to hang a read on
64
+ * — `streamClaudeAgentSdk` returns its stream and claims the SDK query handle in
65
+ * the same tick — which is the whole reason the in-memory prime loses the race.
66
+ * A single small `readFileSync` is what makes turn 1 reachable at all.
67
+ */
68
+ export function readCachedConnectors(
69
+ scopeKey: string = connectorCacheScopeKey(),
70
+ now: number = Date.now(),
71
+ ): ConnectorEntry[] | undefined {
72
+ let raw: string;
73
+ try {
74
+ raw = readFileSync(connectorCachePath(scopeKey), "utf8");
75
+ } catch {
76
+ return undefined;
77
+ }
78
+ let parsed: any;
79
+ try {
80
+ parsed = JSON.parse(raw);
81
+ } catch {
82
+ return undefined;
83
+ }
84
+ if (parsed?.version !== CACHE_VERSION) return undefined;
85
+ // Scope is stored as well as hashed into the path: a hash collision or a
86
+ // hand-copied file would otherwise hand one account another's connectors,
87
+ // which is the exact failure the token-scoping note in connector-inventory.ts
88
+ // warns about.
89
+ if (parsed?.scope !== scopeKey) return undefined;
90
+ const savedAt = typeof parsed?.savedAt === "number" ? parsed.savedAt : 0;
91
+ if (!savedAt || now - savedAt > MAX_AGE_MS || savedAt > now) return undefined;
92
+ if (!Array.isArray(parsed?.connectors)) return undefined;
93
+ const connectors = parsed.connectors.filter(
94
+ (entry: any) => entry && typeof entry.name === "string" && entry.name.trim(),
95
+ );
96
+ return connectors.length > 0 ? (connectors as ConnectorEntry[]) : undefined;
97
+ }
98
+
99
+ /** Best-effort write; a failure here must never affect the turn. */
100
+ export function writeCachedConnectors(
101
+ connectors: ConnectorEntry[],
102
+ scopeKey: string = connectorCacheScopeKey(),
103
+ now: number = Date.now(),
104
+ ): boolean {
105
+ if (!Array.isArray(connectors) || connectors.length === 0) return false;
106
+ const path = connectorCachePath(scopeKey);
107
+ try {
108
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
109
+ writeFileSync(
110
+ path,
111
+ JSON.stringify({ version: CACHE_VERSION, scope: scopeKey, savedAt: now, connectors }),
112
+ { mode: 0o600 },
113
+ );
114
+ return true;
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
@@ -33,6 +33,7 @@
33
33
 
34
34
  const CONNECTOR_NS_PREFIX = "mcp__claude_ai_";
35
35
  const DEFAULT_API_BASE = "https://api.anthropic.com";
36
+ const DEFAULT_PROXY_BASE = "https://mcp-proxy.anthropic.com/v1/mcp";
36
37
  // OAuth-token requests to the Anthropic API require this beta header; without it
37
38
  // endpoints reject the bearer credential.
38
39
  const OAUTH_BETA_HEADER = "oauth-2025-04-20";
@@ -43,6 +44,13 @@ export type ConnectorEntry = {
43
44
  installedServerId?: string;
44
45
  /** Catalog identity, shared across accounts that install the same connector. */
45
46
  directoryUuid?: string;
47
+ /**
48
+ * Account-side install state. `"connected"` marks the connectors the CLI
49
+ * 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.
52
+ */
53
+ installState?: string;
46
54
  description?: string;
47
55
  isAuthless?: boolean;
48
56
  };
@@ -77,6 +85,49 @@ export type ClaudeOAuthCredentials = {
77
85
 
78
86
  type Json = Record<string, any>;
79
87
 
88
+ /**
89
+ * The server name Claude Code itself registers a claude.ai connector under.
90
+ *
91
+ * This is the `mcpServers` KEY to use when declaring a connector explicitly, and
92
+ * it is load-bearing rather than cosmetic: the key IS the tool namespace. Keyed
93
+ * as anything else, the same connector appears twice — once from our
94
+ * declaration and once from the CLI's own loader — and consumers that pin
95
+ * fully-qualified tool names (memsira's executor hard-codes them into
96
+ * `--allowedTools` and its system prompt, and never globs a namespace) would
97
+ * be allowed to call neither copy reliably. Verified live: keyed as the CLI's
98
+ * name there is ONE server entry and one namespace (27 servers); keyed otherwise
99
+ * both appear (28 servers).
100
+ *
101
+ * What merges is the NAMESPACE, not the connection. Under the shared name the
102
+ * declaration and the CLI's own loader each still connect: across 40 cold runs
103
+ * the baseline logged 1 Slack connect (7 proxy connects total) and the declared
104
+ * arm logged 2 Slack connects (8 total), in 20 of 20 runs with no exceptions.
105
+ * Declaring N connectors therefore costs ~2N connections, not N. They run in
106
+ * parallel — slowest-connect per run moved from a 1192ms median to 1278ms, worst
107
+ * 1544ms, well inside the 5s cap — but that was measured with ONE declaration.
108
+ */
109
+ export function connectorServerName(connectorName: string): string {
110
+ return `claude.ai ${connectorName.trim()}`;
111
+ }
112
+
113
+ /**
114
+ * The claude.ai MCP proxy endpoint for one installed connector.
115
+ *
116
+ * The `url` field is REQUIRED by the runtime schema, but the CLI does not
117
+ * connect to it: for `type: "claudeai-proxy"` it derives the endpoint from `id`.
118
+ * Caught live — pointing `url` at a local server that never responds still
119
+ * logged `Using claude.ai proxy at …/mcpsrv_01Wcus…` and connected. So this
120
+ * builds the honest value for a required field; it is `id` that must be right.
121
+ *
122
+ * That also explains why both id forms work: the `mcpsrv_…` id from
123
+ * `GET /v1/mcp_servers` and the `installedServerId` UUID this module returns
124
+ * each connected and served identical tools with the CLI's own connector
125
+ * loading disabled. Both resolve at the proxy; neither depends on `url`.
126
+ */
127
+ export function connectorProxyUrl(installedServerId: string, proxyBase: string = DEFAULT_PROXY_BASE): string {
128
+ return `${trimTrailingSlashes(proxyBase)}/${encodeURIComponent(installedServerId)}`;
129
+ }
130
+
80
131
  /**
81
132
  * Tool-namespace prefix for a connector, e.g. `Google Calendar` →
82
133
  * `mcp__claude_ai_Google_Calendar__`. Connector servers are named after the
@@ -246,6 +297,7 @@ export async function listAccountConnectors(deps: ListConnectorsDeps): Promise<C
246
297
  name,
247
298
  installedServerId: nonEmptyString(entry?.installedServerId),
248
299
  directoryUuid: nonEmptyString(entry?.directoryUuid),
300
+ installState: nonEmptyString(entry?.installState),
249
301
  description: nonEmptyString(entry?.description),
250
302
  isAuthless: typeof entry?.isAuthless === "boolean" ? entry.isAuthless : undefined,
251
303
  });
package/src/connectors.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type HookCallback, type query } from "@anthropic-ai/claude-agent-sdk";
2
2
  import { normalizeConnectorWriteMode, type Config, type ConnectorWriteMode } from "./config.js";
3
3
  import { MCP_SERVER_NAME } from "./skills.js";
4
+ import { connectorProxyUrl, connectorServerName, type ConnectorInventory } from "./connector-inventory.js";
4
5
 
5
6
  // Disable Claude Code built-ins in the provider path. Pi owns tool execution;
6
7
  // Claude reaches Pi tools through the bridged MCP server instead.
@@ -158,6 +159,22 @@ function connectorNameWords(segment: string): string[] {
158
159
  // Explicit known write tool names (current claude.ai connectors). Passed to the
159
160
  // SDK disallowedTools so today's writes are removed from the model's context by
160
161
  // exact tool id (the CLI matcher only supports exact ids or a whole-server glob).
162
+ //
163
+ // PUBLIC CONTRACT — this list and `isConnectorWriteTool` have downstream
164
+ // dependents that gate real user-facing approvals on them (vstack#892):
165
+ //
166
+ // memsira routes connector writes through its own gated approval flow
167
+ // drovr keeps its chat sidecar permanently write-`deny` and runs an
168
+ // approved write as a separate one-shot `claude -p` scoped by
169
+ // `--allowedTools` to exactly one connector tool (drovr#288)
170
+ //
171
+ // Both pin the actions they expose against this classification, because "the
172
+ // sidecar structurally cannot do this itself" is THIS module's claim, not
173
+ // theirs. RECLASSIFYING AN ENTRY HERE AS A READ WOULD MAKE A CONSUMER'S
174
+ // CONFIRMATION CARD BYPASSABLE. Additions are safe and expected; removals and
175
+ // read-verb reclassifications are breaking — coordinate first, see
176
+ // docs/cross-repo.md. `unit-connectors.mjs` pins the set so a change has to be
177
+ // deliberate.
161
178
  export const CONNECTOR_WRITE_TOOLS = [
162
179
  `${CONNECTOR_NS_GMAIL}create_draft`,
163
180
  `${CONNECTOR_NS_GMAIL}create_label`,
@@ -198,6 +215,45 @@ export const CONNECTOR_WRITE_TOOLS = [
198
215
  `${CONNECTOR_NS_ATLASSIAN}createCompassCustomFieldDefinition`,
199
216
  ];
200
217
 
218
+ /**
219
+ * True for a tool that the `claude` CHILD executes itself, so Pi must never be
220
+ * asked to dispatch it.
221
+ *
222
+ * WHY THIS EXISTS. Every other tool the model calls in a bridge turn is a PI
223
+ * tool: Pi hands its tool set to the bridge, the bridge re-offers it to the
224
+ * child through the in-process MCP server, and a `tool_use` coming back is the
225
+ * child asking PI to run something. The bridge is built around that direction —
226
+ * it mirrors the call into the Pi stream, ends the Pi turn with `toolUse`, and
227
+ * the MCP handler blocks until Pi delivers the result.
228
+ *
229
+ * claude.ai connectors run the other way. They are the CHILD's own MCP servers,
230
+ * attached to the authenticated account and reachable only from inside that
231
+ * process. Pi has never heard of them. Mirroring one into the Pi stream anyway
232
+ * made Pi's agent loop look the name up in `context.tools`, miss, and write a
233
+ * synthetic `Tool <name> not found` error result into the transcript — while the
234
+ * child went on and executed the real call. The Pi transcript then RECORDED A
235
+ * FAILURE FOR A CALL THAT SUCCEEDED, next to an answer built from the real
236
+ * payload, so the model's correct answer read as a fabrication (drovr#311,
237
+ * memsira#320). The false result is also projected back into the child's session
238
+ * on a rebuild (`syncSharedSession`), which is how a lie in a mirror becomes a
239
+ * lie in the conversation of record.
240
+ *
241
+ * The test is the NAMESPACE, deliberately, not "does this name resolve to a Pi
242
+ * tool". Every claude.ai connector server lives under `mcp__claude_ai_`, and
243
+ * that is the only tool class the bridge knowingly delegates. A broader
244
+ * "unresolvable ⇒ delegated" rule would also swallow a genuine tool-name
245
+ * mismatch between Pi and the child, which SHOULD still surface as a loud
246
+ * dispatcher error.
247
+ *
248
+ * Takes the RAW SDK tool name, before `mapToolName` — connector names have no
249
+ * Pi-side counterpart, so mapping them is meaningless. Accepts a missing name
250
+ * rather than asserting one: this decides whether Pi is allowed to dispatch a
251
+ * block, and a nameless block is not a connector, so it answers `false`.
252
+ */
253
+ export function isChildExecutedTool(name: string | undefined): boolean {
254
+ return typeof name === "string" && name.startsWith(CONNECTOR_NS_PREFIX);
255
+ }
256
+
201
257
  // Classify a connector tool name as a WRITE (mutating) tool. FAIL CLOSED, twice:
202
258
  //
203
259
  // 1. Namespace: the whole `mcp__claude_ai_<Server>__` space counts, not just the
@@ -306,6 +362,12 @@ export function connectorWriteDenyHook(): HookCallback {
306
362
  };
307
363
  }
308
364
 
365
+ // The deny reason is handed verbatim to the `claude` child's model, so it must
366
+ // stay PRODUCT-NEUTRAL: this is shared source and every consuming app shows it.
367
+ // Naming one host told a different app's model to use a product it has never
368
+ // heard of, which is confusing at exactly the moment someone is debugging a
369
+ // refused write (vstack#892). Each host describes its own approval flow in its
370
+ // own prompt; this string only has to say that one exists.
309
371
  function connectorWriteDenyOutput(toolName: string) {
310
372
  return {
311
373
  hookSpecificOutput: {
@@ -313,7 +375,7 @@ function connectorWriteDenyOutput(toolName: string) {
313
375
  permissionDecision: "deny" as const,
314
376
  permissionDecisionReason:
315
377
  `Connector write tool "${toolName}" is blocked in read-only connector mode. ` +
316
- `Connector writes must go through Memsira's gated approval flow.`,
378
+ `Connector writes must go through the host application's gated approval flow.`,
317
379
  },
318
380
  };
319
381
  }
@@ -357,3 +419,82 @@ export function toolIsolationForQuery(connectorsEnabled: boolean, writeMode: Con
357
419
  allowedTools: [...CLAUDE_BRIDGE_TOOL_ISOLATION.allowedTools, ...CLAUDE_AI_CONNECTOR_TOOL_PATTERNS],
358
420
  };
359
421
  }
422
+
423
+ /**
424
+ * Explicit `mcpServers` declarations for the account's CONNECTED connectors.
425
+ *
426
+ * Why this exists (vstack#832): claude.ai connectors load async and non-blocking,
427
+ * and the turn-1 tool manifest is built at +410-665ms — roughly 300ms BEFORE the
428
+ * CLI has even fetched the connector list. The model therefore composes its first
429
+ * answer against a manifest containing no connectors and says it has no access,
430
+ * while the connector attaches ~1s later and is never asked. Measured end to end
431
+ * on 40 cold sidecars (memsira, 2026-07-26): a connector tool call happened in
432
+ * 7/20 baseline runs versus 20/20 with the declaration, and "I don't have access"
433
+ * went 13/20 → 0/20, one-sided Fisher exact p = 6.4e-6. Confirmed at seven
434
+ * declarations over a further 30 runs: 5/10 → 10/10 calls, 5/10 → 0/10 denials.
435
+ *
436
+ * It is also FASTER, which is the opposite of what the startup barrier suggests.
437
+ * The barrier is real but small and sub-linear — manifest build 490ms none /
438
+ * 1996ms one / 2574ms seven, so seven costs +578ms over one, not 7x. Meanwhile
439
+ * first token drops from a 9840ms median (worst 35.7s) to 6887ms (worst 7.8s),
440
+ * because declaring removes the model's speculative ToolSearch and dead ends.
441
+ * The barrier buys back more than it spends.
442
+ *
443
+ * `alwaysLoad` is the mechanism: it blocks startup until the server is connected
444
+ * (5s cap) precisely "since the tools must be present when the turn-1 prompt is
445
+ * built". It is a field on the server config, so the connector has to be a server
446
+ * WE declare — the CLI's own loader never applies it.
447
+ *
448
+ * Two things here are load-bearing and were established by measurement, not
449
+ * inference:
450
+ *
451
+ * 1. The key MUST be the CLI's own server name (`connectorServerName`). The key
452
+ * is the tool namespace, so any other key yields the connector twice under two
453
+ * namespaces. Consumers that pin fully-qualified tool names rather than
454
+ * globbing a namespace then break.
455
+ * 2. Only `installState === "connected"` connectors are declared. The rest are
456
+ * never attempted by the CLI either, and declaring them would mean asking
457
+ * `alwaysLoad` to block startup on servers that cannot connect.
458
+ *
459
+ * Deliberately NOT typed against the SDK's `McpServerConfig`: that exported union
460
+ * omits the `claudeai-proxy` variant entirely, and its `McpClaudeAIProxyServerConfig`
461
+ * has no `alwaysLoad` field — while the runtime zod schema in the shipped CLI does.
462
+ * Typings and runtime disagree; the runtime honours `alwaysLoad` (verified live),
463
+ * so this builds the object the runtime accepts and casts once, here, with the
464
+ * reason recorded rather than spread across call sites.
465
+ */
466
+ export function connectorMcpServers(inventory: ConnectorInventory): Record<string, unknown> {
467
+ if (!inventory.ok) return {};
468
+ // Escape hatch. `alwaysLoad` holds startup until each declared server
469
+ // connects, and the bound on that wait is NOT established: the SDK doc
470
+ // comment says a 5s cap while the CLI logs `timeout of 30000ms`, and four
471
+ // attempts to force a genuine mid-handshake hang each failed fast for a
472
+ // different reason, so the worst case was never observed. An account with
473
+ // slow or numerous connectors therefore has an unquantified turn-1 delay,
474
+ // and this switch turns declarations off without giving up connectors.
475
+ if (connectorDeclarationsDisabled()) return {};
476
+ const servers: Record<string, unknown> = {};
477
+ for (const entry of inventory.connectors) {
478
+ if (entry.installState !== "connected") continue;
479
+ if (!entry.installedServerId) continue;
480
+ servers[connectorServerName(entry.name)] = {
481
+ type: "claudeai-proxy",
482
+ url: connectorProxyUrl(entry.installedServerId),
483
+ id: entry.installedServerId,
484
+ alwaysLoad: true,
485
+ };
486
+ }
487
+ return servers;
488
+ }
489
+
490
+
491
+ /**
492
+ * `CLAUDE_BRIDGE_CONNECTOR_DECLARE=off` (or `0`/`false`/`no`) disables explicit
493
+ * connector declarations while leaving connectors themselves enabled. Falls back
494
+ * to the pre-#832 behaviour: connectors still load, they just race the turn-1
495
+ * manifest again.
496
+ */
497
+ export function connectorDeclarationsDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
498
+ const v = (env.CLAUDE_BRIDGE_CONNECTOR_DECLARE ?? "").trim().toLowerCase();
499
+ return v === "off" || v === "0" || v === "false" || v === "no";
500
+ }
package/src/convert.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  import type { Message as PiMessage } from "@earendil-works/pi-ai";
5
5
  import type { ContentBlock, Message as SessionMessage } from "cc-session-io";
6
6
  import { pascalCase } from "change-case";
7
+ import { isChildExecutedTool } from "./connectors.js";
7
8
 
8
9
  export const PROVIDER_ID = "claude-bridge";
9
10
 
@@ -21,6 +22,19 @@ export function sanitizeToolId(id: string, cache: Map<string, string>): string {
21
22
 
22
23
  export function mapPiToolNameToSdk(name: string, customToolNameToSdk?: Map<string, string>): string {
23
24
  if (!name) return "";
25
+ // A claude.ai connector name is ALREADY the child's own tool id — the child
26
+ // owns that namespace natively. PascalCasing it invented an alias
27
+ // (`mcp__claude_ai_Slack__slack_search_channels` →
28
+ // `McpClaudeAiSlackSlackSearchChannels`) that appeared in the child's
29
+ // projected history, so the model imitated it on the next turn and got a
30
+ // real `Tool ... not found` from the MCP dispatcher before retrying the
31
+ // canonical name — one wasted round-trip per affected call (memsira#320).
32
+ //
33
+ // Connector names stopped reaching this function at all once they stopped
34
+ // being mirrored as Pi tool calls (isChildExecutedTool), so this is the
35
+ // belt to that braces: LEGACY Pi history recorded before that fix still
36
+ // carries them, and a rebuild would still project the alias.
37
+ if (isChildExecutedTool(name)) return name;
24
38
  const normalized = name.toLowerCase();
25
39
  if (customToolNameToSdk) {
26
40
  const mapped = customToolNameToSdk.get(name) ?? customToolNameToSdk.get(normalized);