@vanillagreen/pi-claude-bridge 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -10
- package/bundle/connector-inventory.js +137 -0
- package/bundle/index.js +8682 -8287
- package/package.json +6 -2
- package/src/agents-md.ts +5 -7
- package/src/assistant-stream.ts +307 -0
- package/src/bridge-state.ts +136 -0
- package/src/claude-executable.ts +264 -0
- package/src/config.ts +13 -7
- package/src/connector-inventory.ts +281 -0
- package/src/connectors.ts +359 -0
- package/src/debug.ts +80 -0
- package/src/index.ts +109 -1579
- package/src/models.ts +22 -1
- package/src/query-state.ts +42 -0
- package/src/rate-limit.ts +63 -0
- package/src/session-persistence.ts +329 -0
- package/src/stream-idle-watchdog.ts +134 -0
- package/src/tool-mapping.ts +53 -0
package/README.md
CHANGED
|
@@ -11,13 +11,14 @@ Forked from [`elidickinson/pi-claude-bridge`](https://github.com/elidickinson/pi
|
|
|
11
11
|
|
|
12
12
|
## Highlights
|
|
13
13
|
|
|
14
|
-
- `claude-bridge/claude-fable-5`,
|
|
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
|
|
@@ -86,7 +87,7 @@ Project settings in `.pi/settings.json` apply only after Pi marks the workspace
|
|
|
86
87
|
| 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
88
|
| Claude executable path | Explicit `claude` binary path; empty auto-detects. |
|
|
88
89
|
|
|
89
|
-
Pi 0.80.6 and newer expose native `max` thinking. Fable 5 bridge metadata
|
|
90
|
+
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
91
|
|
|
91
92
|
```json
|
|
92
93
|
{"claude-opus-4-8":"max"}
|
|
@@ -96,11 +97,39 @@ Keys may be bare model IDs (`claude-opus-4-8`), `claude-bridge/<id>`, or `*` for
|
|
|
96
97
|
|
|
97
98
|
### Connectors
|
|
98
99
|
|
|
99
|
-
|
|
100
|
+
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:
|
|
100
101
|
|
|
101
|
-
|
|
102
|
+
- **Gmail** — search mail, read threads and messages.
|
|
103
|
+
- **Google Calendar** — check calendars and events.
|
|
104
|
+
- **Google Drive** — find and read files.
|
|
105
|
+
- **Slack** — search and read channels, threads, canvases, and people.
|
|
106
|
+
- **Jira and Confluence** — search and read issues, pages, and spaces.
|
|
107
|
+
- Anything else on the account (Figma, org-specific connectors) works the same way — nothing to configure per connector.
|
|
108
|
+
|
|
109
|
+
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.
|
|
110
|
+
|
|
111
|
+
Extension-manager settings use flat package-scoped keys:
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"vstack": {
|
|
116
|
+
"extensionManager": {
|
|
117
|
+
"config": {
|
|
118
|
+
"@vanillagreen/pi-claude-bridge": {
|
|
119
|
+
"enableConnectors": true,
|
|
120
|
+
"connectorWriteMode": "deny"
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
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.
|
|
129
|
+
|
|
130
|
+
| Extension-manager key | Env var | Values | Default | What it does |
|
|
102
131
|
| --- | --- | --- | --- | --- |
|
|
103
|
-
| `enableConnectors` | `CLAUDE_BRIDGE_ENABLE_CONNECTORS` | `true`/`false` | off | Expose the account's
|
|
132
|
+
| `enableConnectors` | `CLAUDE_BRIDGE_ENABLE_CONNECTORS` | `true`/`false` | off | Expose the account's connectors to the model (env OR config enables). |
|
|
104
133
|
| `connectorWriteMode` | `CLAUDE_BRIDGE_CONNECTOR_WRITE` | `deny`/`allow` | `deny` | When connectors are enabled, whether their WRITE tools are exposed. |
|
|
105
134
|
|
|
106
135
|
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).
|
|
@@ -108,7 +137,7 @@ For both, the env var wins over config. `connectorWriteMode` only matters when c
|
|
|
108
137
|
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
138
|
|
|
110
139
|
- **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**
|
|
140
|
+
- **Runtime:** a `PreToolUse` hook blocks any connector tool classified as a write at call time, regardless of permission mode. Classification is **fail-closed** and covers the whole `mcp__claude_ai_<Server>__` space — a tool there is a write unless its name *begins* with a known read verb (`list`, `search`, `get`, `read`, `fetch`, …). The verb is matched as a word across naming styles, since connector servers differ: `search_threads` (Gmail), `slack_read_channel` (Slack, server-prefixed), `getJiraIssue` (Atlassian, camelCase) are all reads; a leading word that merely repeats the server name is skipped first. A name that opens with a read verb but also names a mutation (`getOrCreateChannel`) is a write, and so is a name that does not parse as `<server>__<tool>`. This covers both not-yet-known write tools (e.g. a future Gmail `send_message` or Drive `delete_file`) and connectors beyond the Google trio: claude.ai connectors attach account-wide, so a Slack/Atlassian/org-custom connector is visible in a connector session, and its writes are denied by the same rule.
|
|
112
141
|
|
|
113
142
|
Set `allow` only for a one-shot write-executor session that has already obtained explicit user approval — never for an interactive connector chat.
|
|
114
143
|
|
|
@@ -118,16 +147,37 @@ Set `allow` only for a one-shot write-executor session that has already obtained
|
|
|
118
147
|
|
|
119
148
|
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
149
|
|
|
121
|
-
- no
|
|
150
|
+
- no `AGENTS.md` discovery, including cwd ancestors and the shared `<PI_CODING_AGENT_DIR>/AGENTS.md`;
|
|
151
|
+
- no extension-manager overlay from `<PI_CODING_AGENT_DIR>/settings.json`;
|
|
122
152
|
- no project `.pi/settings.json` / `.pi/claude-bridge.json` reads (even for trusted projects);
|
|
123
153
|
- no project `.pi/APPEND_SYSTEM.md`;
|
|
124
154
|
- no `$PATH` search for the `claude` executable — the host either pins `pathToClaudeCodeExecutable` or gets the Claude Agent SDK's bundled default.
|
|
125
155
|
|
|
126
|
-
|
|
156
|
+
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.
|
|
157
|
+
|
|
158
|
+
### Fable 5 and Opus 5 caveat
|
|
159
|
+
|
|
160
|
+
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.
|
|
127
161
|
|
|
128
|
-
|
|
162
|
+
## Connector inventory
|
|
163
|
+
|
|
164
|
+
`/claude-bridge:connectors` lists the Claude account's installed claude.ai connectors by asking the account, not the model.
|
|
165
|
+
|
|
166
|
+
The older way to answer "does this account have Slack?" was a capability probe: a model turn that enumerated connectors via `ToolSearch`. A search returns what the search surfaced — a lower bound — and nothing in the result said so, so an account with Slack attached could produce an inventory without Slack and no failure signal (vstack#838). This command calls the account's connector list endpoint instead, so the answer is complete by construction.
|
|
167
|
+
|
|
168
|
+
`listAccountConnectors()` is the programmatic form for host apps. Import it from the package's `./connector-inventory` entry point:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
import { listAccountConnectors, resolveClaudeOAuth } from "@vanillagreen/pi-claude-bridge/connector-inventory";
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Consuming apps that regenerate their vendored `package.json` with a closed exports map (`{".": "./bundle/index.js"}`) cannot reach that subpath — Node rejects every unlisted subpath *and* deep path under such a map. The same functions are therefore re-exported from the package root, which is the path those manifests allow:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
import { listAccountConnectors } from "@vanillagreen/pi-claude-bridge";
|
|
178
|
+
```
|
|
129
179
|
|
|
130
|
-
The
|
|
180
|
+
The dedicated entry point is a separate build output. It cannot come from `bundle/index.js`, which exports only pi's extension registration and is tree-shaken against what `index.ts` itself calls — `connectorServerNamespace` was dropped from it entirely for that reason. `tests/unit-connector-inventory-artifact.mjs` loads the built artifact rather than `src/` so a source change without a rebuild fails. 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
181
|
|
|
132
182
|
## Extra usage and rate limits
|
|
133
183
|
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// src/connector-inventory.ts
|
|
2
|
+
var CONNECTOR_NS_PREFIX = "mcp__claude_ai_";
|
|
3
|
+
var DEFAULT_API_BASE = "https://api.anthropic.com";
|
|
4
|
+
var OAUTH_BETA_HEADER = "oauth-2025-04-20";
|
|
5
|
+
function connectorServerNamespace(connectorName) {
|
|
6
|
+
return `${CONNECTOR_NS_PREFIX}${connectorName.trim().replace(/\s+/g, "_")}__`;
|
|
7
|
+
}
|
|
8
|
+
function credentialCandidatePaths(env = process.env) {
|
|
9
|
+
const roots = [];
|
|
10
|
+
const configDir = env.CLAUDE_CONFIG_DIR?.trim();
|
|
11
|
+
if (configDir) roots.push(configDir);
|
|
12
|
+
const home = env.HOME?.trim();
|
|
13
|
+
if (home) roots.push(`${home}/.claude`, home);
|
|
14
|
+
const seen = /* @__PURE__ */ new Set();
|
|
15
|
+
const paths = [];
|
|
16
|
+
for (const root of roots) {
|
|
17
|
+
for (const name of [".credentials.json", ".claude.json"]) {
|
|
18
|
+
const p = `${root}/${name}`;
|
|
19
|
+
if (!seen.has(p)) {
|
|
20
|
+
seen.add(p);
|
|
21
|
+
paths.push(p);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return paths;
|
|
26
|
+
}
|
|
27
|
+
function resolveClaudeOAuth(readFile, env = process.env) {
|
|
28
|
+
let accessToken;
|
|
29
|
+
let organizationUuid;
|
|
30
|
+
for (const path of credentialCandidatePaths(env)) {
|
|
31
|
+
const raw = readFile(path);
|
|
32
|
+
if (!raw) continue;
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = JSON.parse(raw);
|
|
36
|
+
} catch {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
accessToken ??= nonEmptyString(parsed?.claudeAiOauth?.accessToken);
|
|
40
|
+
organizationUuid ??= nonEmptyString(parsed?.oauthAccount?.organizationUuid);
|
|
41
|
+
if (accessToken && organizationUuid) break;
|
|
42
|
+
}
|
|
43
|
+
if (!accessToken || !organizationUuid) return void 0;
|
|
44
|
+
return { accessToken, organizationUuid };
|
|
45
|
+
}
|
|
46
|
+
function nonEmptyString(value) {
|
|
47
|
+
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
48
|
+
}
|
|
49
|
+
function connectorsListUrl(organizationUuid, apiBase = DEFAULT_API_BASE) {
|
|
50
|
+
return `${trimTrailingSlashes(apiBase)}/api/oauth/organizations/${encodeURIComponent(organizationUuid)}/mcp/connectors/list`;
|
|
51
|
+
}
|
|
52
|
+
function trimTrailingSlashes(value) {
|
|
53
|
+
let end = value.length;
|
|
54
|
+
while (end > 0 && value.charCodeAt(end - 1) === 47) end--;
|
|
55
|
+
return value.slice(0, end);
|
|
56
|
+
}
|
|
57
|
+
async function listAccountConnectors(deps) {
|
|
58
|
+
const { credentials, apiBase, signal } = deps;
|
|
59
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
60
|
+
const url = connectorsListUrl(credentials.organizationUuid, apiBase);
|
|
61
|
+
const fail = (reason) => ({ ok: false, complete: false, reason: redactSecret(reason, credentials.accessToken) });
|
|
62
|
+
let response;
|
|
63
|
+
try {
|
|
64
|
+
response = await fetchImpl(url, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: {
|
|
67
|
+
"Authorization": `Bearer ${credentials.accessToken}`,
|
|
68
|
+
"anthropic-beta": OAUTH_BETA_HEADER,
|
|
69
|
+
"Content-Type": "application/json"
|
|
70
|
+
},
|
|
71
|
+
body: "{}",
|
|
72
|
+
signal
|
|
73
|
+
});
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return fail(`connector list request failed: ${errorText(error)}`);
|
|
76
|
+
}
|
|
77
|
+
let bodyText;
|
|
78
|
+
try {
|
|
79
|
+
bodyText = await response.text();
|
|
80
|
+
} catch (error) {
|
|
81
|
+
return fail(`connector list response unreadable: ${errorText(error)}`);
|
|
82
|
+
}
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
return fail(`connector list returned HTTP ${response.status}${apiErrorSuffix(bodyText)}`);
|
|
85
|
+
}
|
|
86
|
+
let parsed;
|
|
87
|
+
try {
|
|
88
|
+
parsed = JSON.parse(bodyText);
|
|
89
|
+
} catch {
|
|
90
|
+
return fail("connector list returned a non-JSON body");
|
|
91
|
+
}
|
|
92
|
+
if (!Array.isArray(parsed?.results)) {
|
|
93
|
+
return fail("connector list response had no results array");
|
|
94
|
+
}
|
|
95
|
+
const connectors = [];
|
|
96
|
+
for (const raw of parsed.results) {
|
|
97
|
+
const entry = raw;
|
|
98
|
+
const name = nonEmptyString(entry?.name);
|
|
99
|
+
if (!name) {
|
|
100
|
+
return fail("connector list contained an entry with no name");
|
|
101
|
+
}
|
|
102
|
+
connectors.push({
|
|
103
|
+
name,
|
|
104
|
+
installedServerId: nonEmptyString(entry?.installedServerId),
|
|
105
|
+
directoryUuid: nonEmptyString(entry?.directoryUuid),
|
|
106
|
+
description: nonEmptyString(entry?.description),
|
|
107
|
+
isAuthless: typeof entry?.isAuthless === "boolean" ? entry.isAuthless : void 0
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return { ok: true, complete: true, connectors };
|
|
111
|
+
}
|
|
112
|
+
function apiErrorSuffix(bodyText) {
|
|
113
|
+
try {
|
|
114
|
+
const message = JSON.parse(bodyText)?.error?.message;
|
|
115
|
+
return typeof message === "string" && message.trim() ? ` (${message.trim()})` : "";
|
|
116
|
+
} catch {
|
|
117
|
+
return "";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function redactSecret(text, secret) {
|
|
121
|
+
if (!secret || secret.length < 8) return text;
|
|
122
|
+
let out = text;
|
|
123
|
+
for (const form of /* @__PURE__ */ new Set([secret, encodeURIComponent(secret)])) {
|
|
124
|
+
out = out.split(form).join("[redacted]");
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
function errorText(error) {
|
|
129
|
+
return error instanceof Error ? error.message : String(error);
|
|
130
|
+
}
|
|
131
|
+
export {
|
|
132
|
+
connectorServerNamespace,
|
|
133
|
+
connectorsListUrl,
|
|
134
|
+
credentialCandidatePaths,
|
|
135
|
+
listAccountConnectors,
|
|
136
|
+
resolveClaudeOAuth
|
|
137
|
+
};
|