@vanillagreen/pi-claude-bridge 1.8.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.
package/README.md CHANGED
@@ -11,17 +11,22 @@ Forked from [`elidickinson/pi-claude-bridge`](https://github.com/elidickinson/pi
11
11
 
12
12
  ## Highlights
13
13
 
14
- - `claude-bridge/claude-fable-5`, Sonnet 5, Opus 4.8, Opus 4.7, Sonnet 4.6, and Haiku in `/model`.
14
+ - `claude-bridge/claude-fable-5`, Opus 5, Opus 4.8, Opus 4.7, Opus 4.6, Sonnet 5, Sonnet 4.6, and Haiku in `/model`. `/model opus` selects Opus 5; older Opus releases stay selectable by full ID.
15
15
  - Pi tool calls run on Pi; Claude Code handles reasoning.
16
16
  - Tool-use turns block until Pi-delivered tool results reach Claude Code, including persistent subagent panes.
17
17
  - Session continuity across normal turns, `/compact`, tree navigation, and abort recovery.
18
18
  - Thinking-level forwarding with summarized Opus thinking display.
19
19
  - Optional Claude effort overrides (`xhigh` → `max` for Opus 4.8).
20
20
  - MCP isolation and Claude cloud-MCP suppression to keep tokens lean.
21
+ - Optional access to your Claude account's connectors — Gmail, Calendar, Drive, Slack, Jira, Confluence — read-only by default.
21
22
  - Opt-in forwarding of `APPEND_SYSTEM.md` and recognized Pi prompt hooks.
22
23
 
23
24
  ## Install
24
25
 
26
+ Requires pi ≥ 0.81 (bridge 2.x registers through pi's native provider API, so pi shows the
27
+ Claude models only while a Claude account is actually connected). On older pi, install
28
+ `@vanillagreen/pi-claude-bridge@1.x` instead.
29
+
25
30
  Via [npm](https://www.npmjs.com/package/@vanillagreen/pi-claude-bridge):
26
31
 
27
32
  ```bash
@@ -86,7 +91,7 @@ Project settings in `.pi/settings.json` apply only after Pi marks the workspace
86
91
  | Model effort overrides | JSON object mapping model IDs to Claude Code efforts, e.g. `{"claude-opus-4-8":"max"}`. Per-model entries beat the global force setting. |
87
92
  | Claude executable path | Explicit `claude` binary path; empty auto-detects. |
88
93
 
89
- Pi 0.80.6 and newer expose native `max` thinking. Fable 5 bridge metadata forwards both `xhigh` and `max`; **Force Claude effort** and **Model effort overrides** remain available when one bridge model needs a different fixed effort. For example, to force only Opus 4.8 to `max`:
94
+ Pi 0.80.6 and newer expose native `max` thinking. Fable 5, Opus 5, and Sonnet 5 bridge metadata forward both `xhigh` and `max`; the generic bridge fallback also maps `max` directly. **Force Claude effort** and **Model effort overrides** remain available when one bridge model needs a different fixed effort. For example, to force only Opus 4.8 to `max`:
90
95
 
91
96
  ```json
92
97
  {"claude-opus-4-8":"max"}
@@ -96,38 +101,96 @@ Keys may be bare model IDs (`claude-opus-4-8`), `claude-bridge/<id>`, or `*` for
96
101
 
97
102
  ### Connectors
98
103
 
99
- The bridge can expose the authenticated Claude account's claude.ai cloud MCP connectors (Gmail, Google Calendar, Google Drive) to the model. These are low-level `provider` options (set in `settings.json` under the extension-manager config, or via env). Off by default so Pi owns tool execution.
104
+ Turn this on and the model can use whatever your Claude account already has connected the same connectors you use in the Claude app, now inside Pi:
105
+
106
+ - **Gmail** — search mail, read threads and messages.
107
+ - **Google Calendar** — check calendars and events.
108
+ - **Google Drive** — find and read files.
109
+ - **Slack** — search and read channels, threads, canvases, and people.
110
+ - **Jira and Confluence** — search and read issues, pages, and spaces.
111
+ - Anything else on the account (Figma, org-specific connectors) works the same way — nothing to configure per connector.
112
+
113
+ Sessions are **read-only** by default: the model can look things up, but cannot send, post, or change anything unless you explicitly turn writes on below.
114
+
115
+ Connector tools run inside Claude Code rather than in Pi, so Pi shows the model's answer but no tool card for the lookup itself. (Before this was handled, Pi showed a card claiming `Tool … not found` for calls that had actually succeeded — so an answer built on real data looked invented.)
116
+
117
+ Each of those lookups is still recorded in the session file as a `claude-bridge-connector-call` entry — the tool name, whether it succeeded, and how many bytes came back, never the contents. So "did it really look that up?" has an answer even though nothing is drawn in the transcript.
118
+
119
+ That entry needs a pi session to be written into. A host that embeds the bridge **without** one — loading it through a bare resource loader, so `extensionApi` is undefined — gets no record at all, and nothing in the bridge can tell. Such a host can install its own destination:
120
+
121
+ ```ts
122
+ import { setConnectorCallAuditSink } from "@vanillagreen/pi-claude-bridge";
100
123
 
101
- | Option (`provider.*`) | Env var | Values | Default | What it does |
124
+ setConnectorCallAuditSink((record) => myOwnAuditTrail(record)); // pass undefined to clear
125
+ ```
126
+
127
+ The sink **adds** a destination; it never replaces the session entry. A session-backed host that installs one gets both, so turning it on can never cost you the record you already had. Same payload-free shape as the entry (`name`, `toolUseId`, `outcome`, and where known `byteSize` / `childSessionId` / `reason`), and the same never-fails-a-turn rule: a sink that throws is caught and dropped. It is process-global, like the bridge's other host handles, so a host running several conversations in one process must route by `childSessionId` itself.
128
+
129
+ Extension-manager settings use flat package-scoped keys:
130
+
131
+ ```json
132
+ {
133
+ "vstack": {
134
+ "extensionManager": {
135
+ "config": {
136
+ "@vanillagreen/pi-claude-bridge": {
137
+ "enableConnectors": true,
138
+ "connectorWriteMode": "deny"
139
+ }
140
+ }
141
+ }
142
+ }
143
+ }
144
+ ```
145
+
146
+ Legacy `.pi/claude-bridge.json` configuration keeps these options nested under `provider`. Environment variables work with either format. Connectors remain off by default so Pi owns tool execution.
147
+
148
+ | Extension-manager key | Env var | Values | Default | What it does |
102
149
  | --- | --- | --- | --- | --- |
103
- | `enableConnectors` | `CLAUDE_BRIDGE_ENABLE_CONNECTORS` | `true`/`false` | off | Expose the account's Gmail/Calendar/Drive connectors to the model (env OR config enables). |
150
+ | `enableConnectors` | `CLAUDE_BRIDGE_ENABLE_CONNECTORS` | `true`/`false` | off | Expose the account's connectors to the model (env OR config enables). |
104
151
  | `connectorWriteMode` | `CLAUDE_BRIDGE_CONNECTOR_WRITE` | `deny`/`allow` | `deny` | When connectors are enabled, whether their WRITE tools are exposed. |
105
152
 
106
153
  For both, the env var wins over config. `connectorWriteMode` only matters when connectors are enabled. Any value other than exactly `allow` is treated as `deny` (fail-closed).
107
154
 
108
- With `connectorWriteMode: "deny"` (the default), connector sessions are **read-only**: search/read/fetch/list tools stay available, but mutating tools (Gmail `create_draft`/labels, Calendar `create_event`/`update_event`/`delete_event`/`respond_to_event`, Drive `create_file`/`copy_file`) are denied. Enforcement is two-layered:
109
-
110
- - **Model context:** the known write tools are passed as `disallowedTools` (exact tool ids), so the model does not see them. (Note: the CLI's MCP permission matcher only supports exact tool names or a whole-server `mcp__server__*` glob — partial tool-segment globs are inert — so exact ids are what actually removes today's writes.)
111
- - **Runtime:** a `PreToolUse` hook blocks any connector tool classified as a write at call time, regardless of permission mode. Classification is **fail-closed** — a tool on a connector namespace is a write unless its verb is a known read prefix (`list_`, `search_`, `get_`, `read_`, `fetch_`, …). This covers not-yet-known write tools (e.g. a future Gmail `send_message` or Drive `delete_file`) that the static id list can't enumerate.
155
+ With `connectorWriteMode: "deny"` (the default), connector sessions are **read-only**: search/read/fetch/list tools stay available, while mutating tools are denied twice the known write tools are removed from the model's tool list, and a runtime hook blocks any connector tool classified as a write at call time, regardless of permission mode. Classification is fail-closed across every connector on the account: a connector tool counts as a write unless its name begins with a known read verb, so not-yet-known write tools and future connectors are denied by the same rule.
112
156
 
113
157
  Set `allow` only for a one-shot write-executor session that has already obtained explicit user approval — never for an interactive connector chat.
114
158
 
115
- > **`allow` is per-process, not global.** Memsira's approved-write executor enables writes by setting `CLAUDE_BRIDGE_CONNECTOR_WRITE=allow` in the **child env of a dedicated one-shot process** that runs the single approved write and exits. Do not set `connectorWriteMode: "allow"` in persistent `settings.json` (or `allow` process-globally) for a shared/long-lived sidecar — that would make every connector session in that process write-capable, defeating the approval gate.
159
+ > **`allow` is per-process, not global.** A host's approved-write executor should set `CLAUDE_BRIDGE_CONNECTOR_WRITE=allow` in the **child env of a dedicated one-shot process** that runs the single approved write and exits. Do not set `connectorWriteMode: "allow"` in persistent `settings.json` (or `allow` process-globally) for a shared/long-lived sidecar — that would make every connector session in that process write-capable, defeating the approval gate.
116
160
 
117
161
  ### Isolated mode (embedding hosts)
118
162
 
119
163
  Host apps that embed the bridge and own every config dir explicitly can set `CLAUDE_BRIDGE_ISOLATED=1` in the bridge process env. Isolated mode disables every cwd/home discovery fallback so nothing outside the host-owned dirs is read:
120
164
 
121
- - no cwd-ancestor `AGENTS.md` walk instructions come only from `<PI_CODING_AGENT_DIR>/AGENTS.md`;
165
+ - no `AGENTS.md` discovery, including cwd ancestors and the shared `<PI_CODING_AGENT_DIR>/AGENTS.md`;
166
+ - no extension-manager overlay from `<PI_CODING_AGENT_DIR>/settings.json`;
122
167
  - no project `.pi/settings.json` / `.pi/claude-bridge.json` reads (even for trusted projects);
123
168
  - no project `.pi/APPEND_SYSTEM.md`;
124
169
  - no `$PATH` search for the `claude` executable — the host either pins `pathToClaudeCodeExecutable` or gets the Claude Agent SDK's bundled default.
125
170
 
126
- Config, logs, and `AGENTS.md` all resolve under `PI_CODING_AGENT_DIR`. Normal pi CLI usage (flag unset) is unchanged.
171
+ Bridge settings come only from the authoritative `<PI_CODING_AGENT_DIR>/claude-bridge.json`; logs still resolve under `PI_CODING_AGENT_DIR`. Normal Pi CLI usage (flag unset) is unchanged.
172
+
173
+ ### Fable 5 and Opus 5 caveat
174
+
175
+ The bridge registers `claude-bridge/claude-fable-5`, `claude-bridge/claude-opus-5`, `claude-bridge/claude-sonnet-5`, and `claude-bridge/claude-opus-4-8` even when Pi's Anthropic model registry has not shipped those entries yet. Fable 5 and Opus 5 both run classifiers that can decline a turn, so for each of them the bridge asks Claude Code to use Opus 4.8 as the availability fallback and preserves Claude Code's content-safety fallback events so Pi labels rerouted turns as Opus 4.8. Content-safety fallback still depends on Claude Code's own Fable 5 support; use Claude Code 2.1.170 or newer, and set `ANTHROPIC_DEFAULT_FABLE_MODEL` / `ANTHROPIC_DEFAULT_OPUS_MODEL` yourself when routing provider-specific model IDs through Bedrock, Vertex, or Foundry.
176
+
177
+ ## Connector inventory
127
178
 
128
- ### Fable 5 caveat
179
+ `/claude-bridge:connectors` lists the Claude account's installed claude.ai connectors by asking the account, not the model, so the answer is complete by construction.
180
+
181
+ `listAccountConnectors()` is the programmatic form for host apps. Import it from the package's `./connector-inventory` entry point:
182
+
183
+ ```ts
184
+ import { listAccountConnectors, resolveClaudeOAuth } from "@vanillagreen/pi-claude-bridge/connector-inventory";
185
+ ```
186
+
187
+ The same functions are re-exported from the package root for consuming apps whose vendored `package.json` uses a closed exports map (`{".": "./bundle/index.js"}`), which blocks every subpath:
188
+
189
+ ```ts
190
+ import { listAccountConnectors } from "@vanillagreen/pi-claude-bridge";
191
+ ```
129
192
 
130
- The bridge registers `claude-bridge/claude-fable-5`, `claude-bridge/claude-sonnet-5`, and `claude-bridge/claude-opus-4-8` even when Pi's Anthropic model registry has not shipped those entries yet. For Fable 5, the bridge asks Claude Code to use Opus 4.8 as the availability fallback and preserves Claude Code's content-safety fallback events so Pi labels rerouted turns as Opus 4.8. Content-safety fallback still depends on Claude Code's own Fable 5 support; use Claude Code 2.1.170 or newer, and set `ANTHROPIC_DEFAULT_FABLE_MODEL` / `ANTHROPIC_DEFAULT_OPUS_MODEL` yourself when routing provider-specific model IDs through Bedrock, Vertex, or Foundry.
193
+ It returns a discriminated result: on success `{ ok: true, complete: true, connectors }`, and on any transport or protocol failure `{ ok: false, reason }`. An account with no connectors is a successful empty list; a failure is never reported as an empty inventory. Credentials resolve from `CLAUDE_CONFIG_DIR` before `$HOME`, so a host running one sidecar per Claude account reads the right account.
131
194
 
132
195
  ## Extra usage and rate limits
133
196
 
@@ -143,7 +206,7 @@ If Claude Code accepts a turn but produces no visible output, the bridge returns
143
206
 
144
207
  Set `CLAUDE_BRIDGE_DEBUG=1` to write bridge logs to `<agent dir>/claude-bridge.log` and per-query Claude Code CLI logs under `<agent dir>/cc-cli-logs/`, where `<agent dir>` is `PI_CODING_AGENT_DIR` when set, else `~/.pi/agent`. Override the exact files with `CLAUDE_BRIDGE_DEBUG_PATH` / `CLAUDE_BRIDGE_DIAG_PATH`.
145
208
 
146
- Tool-result integrity problems are surfaced even when debug logging is off. Pi shows an error notification and writes a diagnostic file to `<agent dir>/claude-bridge-diag.log` so lost or mismatched tool output is visible.
209
+ Tool-result integrity problems are surfaced even when debug logging is off. Pi shows an error notification, writes a diagnostic file to `<agent dir>/claude-bridge-diag.log`, and appends a `claude-bridge-integrity` custom entry to the pi session transcript (compact metadata only — never tool output), so lost or mismatched tool output stays analyzable from the session file alone.
147
210
 
148
211
  Startup failures include the resolved Claude executable and working directory, which makes missing binaries and wrong launch directories easier to fix.
149
212
 
@@ -0,0 +1,147 @@
1
+ // src/connector-inventory.ts
2
+ var CONNECTOR_NS_PREFIX = "mcp__claude_ai_";
3
+ var DEFAULT_API_BASE = "https://api.anthropic.com";
4
+ var DEFAULT_PROXY_BASE = "https://mcp-proxy.anthropic.com/v1/mcp";
5
+ var OAUTH_BETA_HEADER = "oauth-2025-04-20";
6
+ function connectorServerName(connectorName) {
7
+ return `claude.ai ${connectorName.trim()}`;
8
+ }
9
+ function connectorProxyUrl(installedServerId, proxyBase = DEFAULT_PROXY_BASE) {
10
+ return `${trimTrailingSlashes(proxyBase)}/${encodeURIComponent(installedServerId)}`;
11
+ }
12
+ function connectorServerNamespace(connectorName) {
13
+ return `${CONNECTOR_NS_PREFIX}${connectorName.trim().replace(/\s+/g, "_")}__`;
14
+ }
15
+ function credentialCandidatePaths(env = process.env) {
16
+ const roots = [];
17
+ const configDir = env.CLAUDE_CONFIG_DIR?.trim();
18
+ if (configDir) roots.push(configDir);
19
+ const home = env.HOME?.trim();
20
+ if (home) roots.push(`${home}/.claude`, home);
21
+ const seen = /* @__PURE__ */ new Set();
22
+ const paths = [];
23
+ for (const root of roots) {
24
+ for (const name of [".credentials.json", ".claude.json"]) {
25
+ const p = `${root}/${name}`;
26
+ if (!seen.has(p)) {
27
+ seen.add(p);
28
+ paths.push(p);
29
+ }
30
+ }
31
+ }
32
+ return paths;
33
+ }
34
+ function resolveClaudeOAuth(readFile, env = process.env) {
35
+ let accessToken;
36
+ let organizationUuid;
37
+ for (const path of credentialCandidatePaths(env)) {
38
+ const raw = readFile(path);
39
+ if (!raw) continue;
40
+ let parsed;
41
+ try {
42
+ parsed = JSON.parse(raw);
43
+ } catch {
44
+ continue;
45
+ }
46
+ accessToken ??= nonEmptyString(parsed?.claudeAiOauth?.accessToken);
47
+ organizationUuid ??= nonEmptyString(parsed?.oauthAccount?.organizationUuid);
48
+ if (accessToken && organizationUuid) break;
49
+ }
50
+ if (!accessToken || !organizationUuid) return void 0;
51
+ return { accessToken, organizationUuid };
52
+ }
53
+ function nonEmptyString(value) {
54
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
55
+ }
56
+ function connectorsListUrl(organizationUuid, apiBase = DEFAULT_API_BASE) {
57
+ return `${trimTrailingSlashes(apiBase)}/api/oauth/organizations/${encodeURIComponent(organizationUuid)}/mcp/connectors/list`;
58
+ }
59
+ function trimTrailingSlashes(value) {
60
+ let end = value.length;
61
+ while (end > 0 && value.charCodeAt(end - 1) === 47) end--;
62
+ return value.slice(0, end);
63
+ }
64
+ async function listAccountConnectors(deps) {
65
+ const { credentials, apiBase, signal } = deps;
66
+ const fetchImpl = deps.fetchImpl ?? fetch;
67
+ const url = connectorsListUrl(credentials.organizationUuid, apiBase);
68
+ const fail = (reason) => ({ ok: false, complete: false, reason: redactSecret(reason, credentials.accessToken) });
69
+ let response;
70
+ try {
71
+ response = await fetchImpl(url, {
72
+ method: "POST",
73
+ headers: {
74
+ "Authorization": `Bearer ${credentials.accessToken}`,
75
+ "anthropic-beta": OAUTH_BETA_HEADER,
76
+ "Content-Type": "application/json"
77
+ },
78
+ body: "{}",
79
+ signal
80
+ });
81
+ } catch (error) {
82
+ return fail(`connector list request failed: ${errorText(error)}`);
83
+ }
84
+ let bodyText;
85
+ try {
86
+ bodyText = await response.text();
87
+ } catch (error) {
88
+ return fail(`connector list response unreadable: ${errorText(error)}`);
89
+ }
90
+ if (!response.ok) {
91
+ return fail(`connector list returned HTTP ${response.status}${apiErrorSuffix(bodyText)}`);
92
+ }
93
+ let parsed;
94
+ try {
95
+ parsed = JSON.parse(bodyText);
96
+ } catch {
97
+ return fail("connector list returned a non-JSON body");
98
+ }
99
+ if (!Array.isArray(parsed?.results)) {
100
+ return fail("connector list response had no results array");
101
+ }
102
+ const connectors = [];
103
+ for (const raw of parsed.results) {
104
+ const entry = raw;
105
+ const name = nonEmptyString(entry?.name);
106
+ if (!name) {
107
+ return fail("connector list contained an entry with no name");
108
+ }
109
+ connectors.push({
110
+ name,
111
+ installedServerId: nonEmptyString(entry?.installedServerId),
112
+ directoryUuid: nonEmptyString(entry?.directoryUuid),
113
+ installState: nonEmptyString(entry?.installState),
114
+ description: nonEmptyString(entry?.description),
115
+ isAuthless: typeof entry?.isAuthless === "boolean" ? entry.isAuthless : void 0
116
+ });
117
+ }
118
+ return { ok: true, complete: true, connectors };
119
+ }
120
+ function apiErrorSuffix(bodyText) {
121
+ try {
122
+ const message = JSON.parse(bodyText)?.error?.message;
123
+ return typeof message === "string" && message.trim() ? ` (${message.trim()})` : "";
124
+ } catch {
125
+ return "";
126
+ }
127
+ }
128
+ function redactSecret(text, secret) {
129
+ if (!secret || secret.length < 8) return text;
130
+ let out = text;
131
+ for (const form of /* @__PURE__ */ new Set([secret, encodeURIComponent(secret)])) {
132
+ out = out.split(form).join("[redacted]");
133
+ }
134
+ return out;
135
+ }
136
+ function errorText(error) {
137
+ return error instanceof Error ? error.message : String(error);
138
+ }
139
+ export {
140
+ connectorProxyUrl,
141
+ connectorServerName,
142
+ connectorServerNamespace,
143
+ connectorsListUrl,
144
+ credentialCandidatePaths,
145
+ listAccountConnectors,
146
+ resolveClaudeOAuth
147
+ };