@vanillagreen/pi-claude-bridge 1.1.3 → 1.2.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 +8 -1
- package/bundle/index.js +14934 -12019
- package/package.json +23 -6
- package/src/config.ts +3 -0
- package/src/index.ts +401 -11
- package/src/prompt-context.ts +45 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vanillagreen/pi-claude-bridge",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Pi provider bridge that runs Claude Code through the Claude Agent SDK, with opt-in forwarding for Pi prompt context.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -85,6 +85,15 @@
|
|
|
85
85
|
"category": "Claude Code",
|
|
86
86
|
"apply": "live"
|
|
87
87
|
},
|
|
88
|
+
{
|
|
89
|
+
"key": "allowExtraUsage",
|
|
90
|
+
"label": "Allow extra usage helper",
|
|
91
|
+
"description": "Allow claude-bridge to launch Claude Code's /extra-usage flow when rate limits require extra usage. Billing/admin approval still happens in Claude's browser page.",
|
|
92
|
+
"type": "boolean",
|
|
93
|
+
"default": false,
|
|
94
|
+
"category": "Claude Code",
|
|
95
|
+
"apply": "live"
|
|
96
|
+
},
|
|
88
97
|
{
|
|
89
98
|
"key": "pathToClaudeCodeExecutable",
|
|
90
99
|
"label": "Claude executable path",
|
|
@@ -98,7 +107,7 @@
|
|
|
98
107
|
}
|
|
99
108
|
},
|
|
100
109
|
"dependencies": {
|
|
101
|
-
"@anthropic-ai/claude-agent-sdk": "0.2.
|
|
110
|
+
"@anthropic-ai/claude-agent-sdk": "0.2.141",
|
|
102
111
|
"@anthropic-ai/sdk": "^0.73.0",
|
|
103
112
|
"cc-session-io": "^0.3.1",
|
|
104
113
|
"change-case": "^5.4.4"
|
|
@@ -108,15 +117,15 @@
|
|
|
108
117
|
"@earendil-works/pi-coding-agent": "*"
|
|
109
118
|
},
|
|
110
119
|
"devDependencies": {
|
|
111
|
-
"@earendil-works/pi-ai": "^0.
|
|
112
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
120
|
+
"@earendil-works/pi-ai": "^0.75.0",
|
|
121
|
+
"@earendil-works/pi-coding-agent": "^0.75.0",
|
|
113
122
|
"@types/node": "^24.3.0",
|
|
114
123
|
"esbuild": "^0.28.0",
|
|
115
124
|
"tsx": "^4.21.0",
|
|
116
125
|
"typescript": "^6.0.3"
|
|
117
126
|
},
|
|
118
127
|
"scripts": {
|
|
119
|
-
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=
|
|
128
|
+
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node22 --outfile=bundle/index.js --external:@earendil-works/pi-ai --external:@earendil-works/pi-coding-agent",
|
|
120
129
|
"prepack": "npm run build",
|
|
121
130
|
"test:unit": "node --import tsx --test tests/unit-*.mjs",
|
|
122
131
|
"test": "set -a && [ -f .env.test ] && . ./.env.test; set +a && npm run test:unit && tests/int-smoke.sh && tests/int-multi-turn.sh && tests/int-cache.sh && node --import tsx --test tests/int-*.mjs",
|
|
@@ -145,6 +154,14 @@
|
|
|
145
154
|
"access": "public"
|
|
146
155
|
},
|
|
147
156
|
"engines": {
|
|
148
|
-
"node": ">=
|
|
157
|
+
"node": ">=22.19.0"
|
|
158
|
+
},
|
|
159
|
+
"peerDependenciesMeta": {
|
|
160
|
+
"@earendil-works/pi-ai": {
|
|
161
|
+
"optional": true
|
|
162
|
+
},
|
|
163
|
+
"@earendil-works/pi-coding-agent": {
|
|
164
|
+
"optional": true
|
|
165
|
+
}
|
|
149
166
|
}
|
|
150
167
|
}
|
package/src/config.ts
CHANGED
|
@@ -14,6 +14,7 @@ export interface Config {
|
|
|
14
14
|
/** Low-level Claude Agent SDK plumbing. Most users won't need these. */
|
|
15
15
|
provider?: {
|
|
16
16
|
appendSystemPrompt?: boolean;
|
|
17
|
+
allowExtraUsage?: boolean;
|
|
17
18
|
settingSources?: SettingSource[];
|
|
18
19
|
strictMcpConfig?: boolean;
|
|
19
20
|
pathToClaudeCodeExecutable?: string;
|
|
@@ -111,6 +112,8 @@ function managerToConfig(raw: SettingsRecord): Partial<Config> {
|
|
|
111
112
|
|
|
112
113
|
const appendSystemPrompt = boolFrom(raw, "appendSystemPrompt");
|
|
113
114
|
if (appendSystemPrompt !== undefined) provider.appendSystemPrompt = appendSystemPrompt;
|
|
115
|
+
const allowExtraUsage = boolFrom(raw, "allowExtraUsage");
|
|
116
|
+
if (allowExtraUsage !== undefined) provider.allowExtraUsage = allowExtraUsage;
|
|
114
117
|
const strictMcpConfig = boolFrom(raw, "strictMcpConfig");
|
|
115
118
|
if (strictMcpConfig !== undefined) provider.strictMcpConfig = strictMcpConfig;
|
|
116
119
|
const claudePath = stringFrom(raw, "pathToClaudeCodeExecutable");
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { calculateCost, getModels, type AssistantMessage, type AssistantMessageEventStream, type Context, type Model, type SimpleStreamOptions, type Tool } from "@earendil-works/pi-ai";
|
|
2
2
|
import * as piAi from "@earendil-works/pi-ai";
|
|
3
3
|
import { type ExtensionAPI, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { createSdkMcpServer, query, type EffortLevel, type SDKMessage, type SDKUserMessage, type SettingSource } from "@anthropic-ai/claude-agent-sdk";
|
|
4
|
+
import { createSdkMcpServer, query, type EffortLevel, type SDKMessage, type SDKUserMessage, type SettingSource, type SpawnOptions, type SpawnedProcess } from "@anthropic-ai/claude-agent-sdk";
|
|
5
5
|
import type { Base64ImageSource, ContentBlockParam, MessageParam } from "@anthropic-ai/sdk/resources";
|
|
6
6
|
import { createSession, deleteSession, openSession, repairToolPairing } from "cc-session-io";
|
|
7
|
+
import { spawn as spawnProcess } from "child_process";
|
|
7
8
|
import { createHash } from "crypto";
|
|
8
|
-
import { accessSync, appendFileSync, constants as fsConstants, mkdirSync, realpathSync, statSync } from "fs";
|
|
9
|
+
import { accessSync, appendFileSync, constants as fsConstants, mkdirSync, readFileSync, realpathSync, statSync } from "fs";
|
|
9
10
|
import { resolve as pathResolve } from "path";
|
|
10
11
|
import { homedir } from "os";
|
|
11
12
|
import { delimiter, dirname, join } from "path";
|
|
@@ -15,7 +16,7 @@ import { MCP_SERVER_NAME, MCP_TOOL_PREFIX, extractSkillsBlock } from "./skills.j
|
|
|
15
16
|
import { verifyWrittenSession as _verifyWrittenSession } from "./session-verify.js";
|
|
16
17
|
import { extractAllToolResults as _extractAllToolResults, type McpResult } from "./extract-tool-results.js";
|
|
17
18
|
import { QueryContext, ctx, stackDepth, pushContext, popContext } from "./query-state.js";
|
|
18
|
-
import { loadConfig } from "./config.js";
|
|
19
|
+
import { loadConfig, type Config } from "./config.js";
|
|
19
20
|
import { extractAgentsAppend } from "./agents-md.js";
|
|
20
21
|
import { buildPromptContextAppend } from "./prompt-context.js";
|
|
21
22
|
import { jsonSchemaToZodShape } from "./typebox-to-zod.js";
|
|
@@ -79,6 +80,240 @@ function resolveClaudeExecutable(configured?: string): string | undefined {
|
|
|
79
80
|
return executableFromPath("claude") ?? executableFromPath("claude-code");
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
export type ClaudeExecutableFileType = "elf" | "mach-o" | "pe" | "shebang-script" | "empty" | "unknown";
|
|
84
|
+
|
|
85
|
+
export interface ClaudeExecutablePreflightResult {
|
|
86
|
+
path: string;
|
|
87
|
+
realPath: string;
|
|
88
|
+
cwd: string;
|
|
89
|
+
realCwd: string;
|
|
90
|
+
fileType: ClaudeExecutableFileType;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function errnoValue(err: unknown): string | number | undefined {
|
|
94
|
+
return typeof (err as NodeJS.ErrnoException)?.errno === "number" ? (err as NodeJS.ErrnoException).errno : undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function syscallValue(err: unknown): string | undefined {
|
|
98
|
+
return typeof (err as NodeJS.ErrnoException)?.syscall === "string" ? (err as NodeJS.ErrnoException).syscall : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function pathValue(err: unknown): string | undefined {
|
|
102
|
+
const value = (err as NodeJS.ErrnoException)?.path;
|
|
103
|
+
return typeof value === "string" ? value : undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function codeValue(err: unknown, fallback: string): string {
|
|
107
|
+
const value = (err as NodeJS.ErrnoException)?.code;
|
|
108
|
+
return typeof value === "string" ? value : fallback;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function displayValue(value: unknown): string {
|
|
112
|
+
return value === undefined || value === null || value === "" ? "<none>" : String(value);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function makeClaudePreflightError(
|
|
116
|
+
summary: string,
|
|
117
|
+
details: { code: string; errno?: string | number; syscall?: string; path: string; cwd: string; fileType?: ClaudeExecutableFileType; realPath?: string; cause?: unknown },
|
|
118
|
+
): Error & NodeJS.ErrnoException & { cwd: string; fileType?: ClaudeExecutableFileType; realPath?: string } {
|
|
119
|
+
const detail = [
|
|
120
|
+
`code=${details.code}`,
|
|
121
|
+
`errno=${displayValue(details.errno)}`,
|
|
122
|
+
`syscall=${displayValue(details.syscall)}`,
|
|
123
|
+
`path=${details.path}`,
|
|
124
|
+
`cwd=${details.cwd}`,
|
|
125
|
+
...(details.fileType ? [`fileType=${details.fileType}`] : []),
|
|
126
|
+
...(details.realPath ? [`realPath=${details.realPath}`] : []),
|
|
127
|
+
].join(" ");
|
|
128
|
+
const error = new Error(`${summary} (${detail})`) as Error & NodeJS.ErrnoException & { cwd: string; fileType?: ClaudeExecutableFileType; realPath?: string };
|
|
129
|
+
error.name = "ClaudeExecutablePreflightError";
|
|
130
|
+
error.code = details.code;
|
|
131
|
+
if (details.errno !== undefined) error.errno = typeof details.errno === "number" ? details.errno : Number(details.errno);
|
|
132
|
+
if (details.syscall) error.syscall = details.syscall;
|
|
133
|
+
error.path = details.path;
|
|
134
|
+
error.cwd = details.cwd;
|
|
135
|
+
if (details.fileType) error.fileType = details.fileType;
|
|
136
|
+
if (details.realPath) error.realPath = details.realPath;
|
|
137
|
+
if (details.cause !== undefined) (error as Error & { cause?: unknown }).cause = details.cause;
|
|
138
|
+
return error;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function classifyClaudeExecutableBytes(bytes: Uint8Array): ClaudeExecutableFileType {
|
|
142
|
+
if (bytes.length === 0) return "empty";
|
|
143
|
+
if (bytes.length >= 2 && bytes[0] === 0x23 && bytes[1] === 0x21) return "shebang-script";
|
|
144
|
+
if (bytes.length >= 4 && bytes[0] === 0x7f && bytes[1] === 0x45 && bytes[2] === 0x4c && bytes[3] === 0x46) return "elf";
|
|
145
|
+
if (bytes.length >= 2 && bytes[0] === 0x4d && bytes[1] === 0x5a) return "pe";
|
|
146
|
+
if (bytes.length >= 4) {
|
|
147
|
+
const magic = bytes[0] * 0x1000000 + bytes[1] * 0x10000 + bytes[2] * 0x100 + bytes[3];
|
|
148
|
+
if (
|
|
149
|
+
magic === 0xfeedface ||
|
|
150
|
+
magic === 0xfeedfacf ||
|
|
151
|
+
magic === 0xcefaedfe ||
|
|
152
|
+
magic === 0xcffaedfe ||
|
|
153
|
+
magic === 0xcafebabe ||
|
|
154
|
+
magic === 0xbebafeca
|
|
155
|
+
) return "mach-o";
|
|
156
|
+
}
|
|
157
|
+
return "unknown";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function preflightClaudeExecutable(path: string, cwd: string): ClaudeExecutablePreflightResult {
|
|
161
|
+
let realCwd = cwd;
|
|
162
|
+
try {
|
|
163
|
+
const cwdStat = statSync(cwd);
|
|
164
|
+
if (!cwdStat.isDirectory()) {
|
|
165
|
+
throw makeClaudePreflightError("Claude Code spawn cwd preflight failed: cwd is not a directory.", {
|
|
166
|
+
code: "ENOTDIR",
|
|
167
|
+
syscall: "chdir",
|
|
168
|
+
path: cwd,
|
|
169
|
+
cwd,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
accessSync(cwd, fsConstants.X_OK);
|
|
173
|
+
realCwd = realpathSync(cwd);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
if ((err as Error).name === "ClaudeExecutablePreflightError") throw err;
|
|
176
|
+
throw makeClaudePreflightError("Claude Code spawn cwd preflight failed: cwd is not reachable before spawning Claude Code.", {
|
|
177
|
+
code: codeValue(err, "EACCES"),
|
|
178
|
+
errno: errnoValue(err),
|
|
179
|
+
syscall: syscallValue(err),
|
|
180
|
+
path: pathValue(err) ?? cwd,
|
|
181
|
+
cwd,
|
|
182
|
+
cause: err,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let realPath = path;
|
|
187
|
+
try {
|
|
188
|
+
const stat = statSync(path);
|
|
189
|
+
if (!stat.isFile()) {
|
|
190
|
+
throw makeClaudePreflightError("Claude Code executable preflight failed: resolved path is not a file.", {
|
|
191
|
+
code: "EACCES",
|
|
192
|
+
syscall: "exec",
|
|
193
|
+
path,
|
|
194
|
+
cwd,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
accessSync(path, fsConstants.X_OK);
|
|
198
|
+
realPath = realpathSync(path);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
if ((err as Error).name === "ClaudeExecutablePreflightError") throw err;
|
|
201
|
+
throw makeClaudePreflightError("Claude Code executable preflight failed: cannot access resolved executable before spawning Claude Code.", {
|
|
202
|
+
code: codeValue(err, "ENOENT"),
|
|
203
|
+
errno: errnoValue(err),
|
|
204
|
+
syscall: syscallValue(err),
|
|
205
|
+
path: pathValue(err) ?? path,
|
|
206
|
+
cwd,
|
|
207
|
+
cause: err,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let fileType: ClaudeExecutableFileType;
|
|
212
|
+
try {
|
|
213
|
+
fileType = classifyClaudeExecutableBytes(readFileSync(realPath).subarray(0, 16));
|
|
214
|
+
} catch (err) {
|
|
215
|
+
throw makeClaudePreflightError("Claude Code executable preflight failed: cannot read executable header before spawning Claude Code.", {
|
|
216
|
+
code: codeValue(err, "EACCES"),
|
|
217
|
+
errno: errnoValue(err),
|
|
218
|
+
syscall: syscallValue(err),
|
|
219
|
+
path: pathValue(err) ?? realPath,
|
|
220
|
+
cwd,
|
|
221
|
+
realPath,
|
|
222
|
+
cause: err,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!["elf", "mach-o", "pe", "shebang-script"].includes(fileType)) {
|
|
227
|
+
throw makeClaudePreflightError("Claude Code executable preflight failed: executable header is not an ELF, Mach-O, PE, or shebang script.", {
|
|
228
|
+
code: "ENOEXEC",
|
|
229
|
+
syscall: "exec",
|
|
230
|
+
path,
|
|
231
|
+
cwd,
|
|
232
|
+
fileType,
|
|
233
|
+
realPath,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return { path, realPath, cwd, realCwd, fileType };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function envFlagEnabled(value: string | undefined): boolean {
|
|
241
|
+
return value === "1" || value?.toLowerCase() === "true";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function wrapClaudeSpawnErrorForSdk(err: Error, options: SpawnOptions): Error & NodeJS.ErrnoException & { cwd: string; originalCode?: string; originalMessage?: string } {
|
|
245
|
+
const originalCode = codeValue(err, "SPAWN_ERROR");
|
|
246
|
+
const originalMessage = err.message;
|
|
247
|
+
const spawnPath = pathValue(err) ?? options.command;
|
|
248
|
+
const cwd = options.cwd ?? process.cwd();
|
|
249
|
+
const detail = [
|
|
250
|
+
`code=${originalCode}`,
|
|
251
|
+
`errno=${displayValue(errnoValue(err))}`,
|
|
252
|
+
`syscall=${displayValue(syscallValue(err))}`,
|
|
253
|
+
`path=${spawnPath}`,
|
|
254
|
+
`cwd=${cwd}`,
|
|
255
|
+
`command=${options.command}`,
|
|
256
|
+
].join(" ");
|
|
257
|
+
const wrapped = new Error(`Claude Code spawn failed: ${originalMessage} (${detail})`) as Error & NodeJS.ErrnoException & { cwd: string; originalCode?: string; originalMessage?: string };
|
|
258
|
+
wrapped.name = "ClaudeSpawnDiagnosticError";
|
|
259
|
+
// The SDK special-cases code === ENOENT and replaces the message with its
|
|
260
|
+
// generic "native binary not found" text. Preserve the original code in the
|
|
261
|
+
// message/originalCode while using a bridge code so the SDK surfaces context.
|
|
262
|
+
wrapped.code = originalCode === "ENOENT" ? "CLAUDE_BRIDGE_SPAWN_FAILED" : originalCode;
|
|
263
|
+
wrapped.originalCode = originalCode;
|
|
264
|
+
wrapped.originalMessage = originalMessage;
|
|
265
|
+
const errno = errnoValue(err);
|
|
266
|
+
if (errno !== undefined) wrapped.errno = typeof errno === "number" ? errno : Number(errno);
|
|
267
|
+
const syscall = syscallValue(err);
|
|
268
|
+
if (syscall) wrapped.syscall = syscall;
|
|
269
|
+
wrapped.path = spawnPath;
|
|
270
|
+
wrapped.cwd = cwd;
|
|
271
|
+
// Do not set `cause` here: the listener copies these structured fields back
|
|
272
|
+
// onto the original Error. A cause reference to that same object would become
|
|
273
|
+
// `err.cause === err`, making JSON.stringify throw on a circular structure.
|
|
274
|
+
// originalMessage plus code/errno/syscall/path/cwd preserve the useful data.
|
|
275
|
+
return wrapped;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function spawnClaudeCodeWithDiagnostics(options: SpawnOptions): SpawnedProcess {
|
|
279
|
+
const pipeStderr = DEBUG || envFlagEnabled(options.env.DEBUG_CLAUDE_AGENT_SDK);
|
|
280
|
+
const child = spawnProcess(options.command, options.args, {
|
|
281
|
+
cwd: options.cwd,
|
|
282
|
+
env: options.env,
|
|
283
|
+
signal: options.signal,
|
|
284
|
+
stdio: ["pipe", "pipe", pipeStderr ? "pipe" : "ignore"],
|
|
285
|
+
windowsHide: true,
|
|
286
|
+
});
|
|
287
|
+
if (pipeStderr) {
|
|
288
|
+
child.stderr?.on("data", (data) => {
|
|
289
|
+
for (const line of data.toString().split(/\r?\n/)) {
|
|
290
|
+
if (line) debug(`[cli-stderr spawn] ${line}`);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
child.prependListener("error", (err) => {
|
|
295
|
+
const originalStack = err.stack;
|
|
296
|
+
const wrapped = wrapClaudeSpawnErrorForSdk(err, options);
|
|
297
|
+
Object.assign(err, wrapped);
|
|
298
|
+
err.name = wrapped.name;
|
|
299
|
+
err.message = wrapped.message;
|
|
300
|
+
// Keep V8's stack from the actual Node spawn failure, not the wrapper
|
|
301
|
+
// construction site. Diagnostic fields above remain enumerable and
|
|
302
|
+
// JSON-serializable; stack stays the spawn-time breadcrumb for operators.
|
|
303
|
+
if (originalStack) err.stack = originalStack;
|
|
304
|
+
});
|
|
305
|
+
return {
|
|
306
|
+
stdin: child.stdin,
|
|
307
|
+
stdout: child.stdout,
|
|
308
|
+
get killed() { return child.killed; },
|
|
309
|
+
get exitCode() { return child.exitCode; },
|
|
310
|
+
kill: child.kill.bind(child),
|
|
311
|
+
on: child.on.bind(child),
|
|
312
|
+
once: child.once.bind(child),
|
|
313
|
+
off: child.off.bind(child),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
82
317
|
// Per-query CLI debug capture. When CLAUDE_BRIDGE_DEBUG=1, ask the Claude Code
|
|
83
318
|
// CLI subprocess to write its own debug log to a file we choose, and also
|
|
84
319
|
// forward its stderr into our debug stream. Drops straight into the real SDK's
|
|
@@ -130,6 +365,7 @@ function diagDump(label: string, data: Record<string, unknown>) {
|
|
|
130
365
|
// On session_shutdown (including /reload), clearSession() resets this so a fresh
|
|
131
366
|
// registration can occur for the next session.
|
|
132
367
|
const ACTIVE_STREAM_SIMPLE_KEY = Symbol.for("claude-bridge:activeStreamSimple");
|
|
368
|
+
const COMMANDS_REGISTERED_KEY = Symbol.for("claude-bridge:commandsRegistered");
|
|
133
369
|
|
|
134
370
|
const SDK_TO_PI_TOOL_NAME: Record<string, string> = {
|
|
135
371
|
read: "read", write: "write", edit: "edit", bash: "bash",
|
|
@@ -187,6 +423,82 @@ interface SessionState {
|
|
|
187
423
|
|
|
188
424
|
let sharedSession: SessionState | null = null;
|
|
189
425
|
let extensionApi: ExtensionAPI | undefined;
|
|
426
|
+
let piUI: ExtensionUIContext | undefined;
|
|
427
|
+
let extraUsageHelperInFlight: Promise<string> | null = null;
|
|
428
|
+
|
|
429
|
+
export function isExtraUsageRequiredMessage(value: unknown): boolean {
|
|
430
|
+
let text: string;
|
|
431
|
+
if (typeof value === "string") text = value;
|
|
432
|
+
else if (value instanceof Error) text = value.message;
|
|
433
|
+
else {
|
|
434
|
+
try { text = JSON.stringify(value ?? ""); }
|
|
435
|
+
catch { text = String(value); }
|
|
436
|
+
}
|
|
437
|
+
return /extra[-\s]?usage|overage|extra usage billing|extra usage credits|1M context/i.test(text);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function extraUsageAllowed(config: Config): boolean {
|
|
441
|
+
return config.provider?.allowExtraUsage === true;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function sdkTextFromMessage(message: SDKMessage): string | undefined {
|
|
445
|
+
if (message.type === "result") return (message as any).result;
|
|
446
|
+
if (message.type === "assistant") {
|
|
447
|
+
const content = (message as any).message?.content;
|
|
448
|
+
if (!Array.isArray(content)) return undefined;
|
|
449
|
+
return content
|
|
450
|
+
.map((block) => block?.type === "text" && typeof block.text === "string" ? block.text : "")
|
|
451
|
+
.filter(Boolean)
|
|
452
|
+
.join("\n");
|
|
453
|
+
}
|
|
454
|
+
return undefined;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function runExtraUsageHelper(cwd: string, config = loadConfig(cwd)): Promise<string> {
|
|
458
|
+
const providerSettings = config.provider ?? {};
|
|
459
|
+
const claudeExecutable = resolveClaudeExecutable(providerSettings.pathToClaudeCodeExecutable);
|
|
460
|
+
if (claudeExecutable) preflightClaudeExecutable(claudeExecutable, cwd);
|
|
461
|
+
|
|
462
|
+
const helperQuery = query({
|
|
463
|
+
prompt: "/extra-usage",
|
|
464
|
+
options: {
|
|
465
|
+
cwd,
|
|
466
|
+
env: { ...process.env, ENABLE_CLAUDEAI_MCP_SERVERS: "0", DISABLE_AUTO_COMPACT: "1" },
|
|
467
|
+
maxTurns: 1,
|
|
468
|
+
...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
|
|
469
|
+
spawnClaudeCodeProcess: spawnClaudeCodeWithDiagnostics,
|
|
470
|
+
...makeCliDebugOptions("extra-usage"),
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
const outputs: string[] = [];
|
|
474
|
+
try {
|
|
475
|
+
for await (const message of helperQuery) {
|
|
476
|
+
const text = sdkTextFromMessage(message)?.trim();
|
|
477
|
+
if (text && outputs[outputs.length - 1] !== text) outputs.push(text);
|
|
478
|
+
}
|
|
479
|
+
} finally {
|
|
480
|
+
helperQuery.close();
|
|
481
|
+
}
|
|
482
|
+
return outputs.join("\n").trim() || "Claude Code /extra-usage completed.";
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function launchExtraUsageHelperIfAllowed(cwd: string, config: Config, reason: string): boolean {
|
|
486
|
+
if (!extraUsageAllowed(config)) return false;
|
|
487
|
+
if (extraUsageHelperInFlight) return true;
|
|
488
|
+
extraUsageHelperInFlight = runExtraUsageHelper(cwd, config)
|
|
489
|
+
.then((message) => {
|
|
490
|
+
piUI?.notify(`Claude extra usage helper: ${message}`, "info");
|
|
491
|
+
return message;
|
|
492
|
+
})
|
|
493
|
+
.catch((error) => {
|
|
494
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
495
|
+
piUI?.notify(`Claude extra usage helper failed after ${reason}: ${message}`, "error");
|
|
496
|
+
throw error;
|
|
497
|
+
})
|
|
498
|
+
.finally(() => { extraUsageHelperInFlight = null; });
|
|
499
|
+
void extraUsageHelperInFlight.catch(() => {});
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
190
502
|
|
|
191
503
|
const BRIDGE_SESSION_CUSTOM_TYPE = "claude-bridge-session";
|
|
192
504
|
|
|
@@ -616,9 +928,6 @@ function mapToolArgs(
|
|
|
616
928
|
// them without activating the extension. `ctx()`, `pushContext()`, `popContext()`
|
|
617
929
|
// are imported at the top of this file.
|
|
618
930
|
|
|
619
|
-
// Global (not query state):
|
|
620
|
-
let piUI: ExtensionUIContext | null = null;
|
|
621
|
-
|
|
622
931
|
function resolveMcpTools(context: Context, excludeToolName?: string): {
|
|
623
932
|
mcpTools: Tool[];
|
|
624
933
|
customToolNameToSdk: Map<string, string>;
|
|
@@ -921,6 +1230,8 @@ async function consumeQuery(
|
|
|
921
1230
|
sdkQuery: ReturnType<typeof query>,
|
|
922
1231
|
customToolNameToPi: Map<string, string>,
|
|
923
1232
|
model: Model<any>,
|
|
1233
|
+
cwd: string,
|
|
1234
|
+
bridgeConfig: Config,
|
|
924
1235
|
wasAborted: () => boolean,
|
|
925
1236
|
): Promise<{ capturedSessionId?: string }> {
|
|
926
1237
|
let capturedSessionId: string | undefined;
|
|
@@ -945,6 +1256,14 @@ async function consumeQuery(
|
|
|
945
1256
|
ctx().currentPiStream?.push({ type: "text_start", contentIndex: idx, partial: ctx().turnOutput });
|
|
946
1257
|
ctx().currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: text, partial: ctx().turnOutput });
|
|
947
1258
|
ctx().currentPiStream?.push({ type: "text_end", contentIndex: idx, content: text, partial: ctx().turnOutput });
|
|
1259
|
+
} else if (message.subtype !== "success" && isExtraUsageRequiredMessage(message)) {
|
|
1260
|
+
const errors = Array.isArray((message as any).errors) ? (message as any).errors.join("\n") : message.subtype;
|
|
1261
|
+
const openedExtraUsage = launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "result error");
|
|
1262
|
+
ctx().turnOutput.stopReason = "error";
|
|
1263
|
+
ctx().turnOutput.errorMessage = `${errors}${openedExtraUsage ? "\n\nOpened Claude Code /extra-usage helper. Complete billing/admin flow in the browser, then retry the prompt." : "\n\nRun /claude-bridge:extra, or enable Allow extra usage helper in settings."}`;
|
|
1264
|
+
ctx().currentPiStream?.push({ type: "error", reason: "error", error: ctx().turnOutput });
|
|
1265
|
+
ctx().currentPiStream?.end();
|
|
1266
|
+
ctx().currentPiStream = null;
|
|
948
1267
|
}
|
|
949
1268
|
break;
|
|
950
1269
|
case "system":
|
|
@@ -959,7 +1278,9 @@ async function consumeQuery(
|
|
|
959
1278
|
debug("consumeQuery: rate_limit_event", JSON.stringify(info).slice(0, 300));
|
|
960
1279
|
if (info?.status === "rejected") {
|
|
961
1280
|
const resetsAt = info.resetsAt ? new Date(info.resetsAt).toLocaleTimeString() : "unknown";
|
|
962
|
-
|
|
1281
|
+
const reason = `${info.rateLimitType ?? "unknown"} rate limit`;
|
|
1282
|
+
const launchedExtraUsage = isExtraUsageRequiredMessage(info) && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, reason);
|
|
1283
|
+
piUI?.notify(`Claude rate limited (${reason}) — resets at ${resetsAt}${launchedExtraUsage ? "; opened /extra-usage helper" : ""}`, "warning");
|
|
963
1284
|
} else if (info?.status === "allowed_warning") {
|
|
964
1285
|
piUI?.notify(`Claude rate limit warning: ${Math.round(info.utilization ?? 0)}% used (${info.rateLimitType ?? ""})`, "warning");
|
|
965
1286
|
}
|
|
@@ -1072,7 +1393,6 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1072
1393
|
|
|
1073
1394
|
const { mcpTools, customToolNameToSdk, customToolNameToPi } = resolveMcpTools(context);
|
|
1074
1395
|
const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
|
|
1075
|
-
const { sessionId: resumeSessionId } = syncSharedSession(context.messages, cwd, customToolNameToSdk, model.id);
|
|
1076
1396
|
const promptBlocks = extractUserPromptBlocks(context.messages);
|
|
1077
1397
|
let promptText = extractUserPrompt(context.messages) ?? "";
|
|
1078
1398
|
|
|
@@ -1114,6 +1434,8 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1114
1434
|
: providerSettings.settingSources ?? ["user", "project"];
|
|
1115
1435
|
const strictMcpConfigEnabled = !appendSystemPrompt && providerSettings.strictMcpConfig !== false;
|
|
1116
1436
|
const claudeExecutable = resolveClaudeExecutable(providerSettings.pathToClaudeCodeExecutable);
|
|
1437
|
+
const claudeExecutablePreflight = claudeExecutable ? preflightClaudeExecutable(claudeExecutable, cwd) : undefined;
|
|
1438
|
+
const { sessionId: resumeSessionId } = syncSharedSession(context.messages, cwd, customToolNameToSdk, model.id);
|
|
1117
1439
|
|
|
1118
1440
|
// Prefer the model's own thinkingLevelMap when present (pi-ai 0.72+ ships
|
|
1119
1441
|
// per-model overrides — e.g. opus-4-7 wants xhigh→xhigh, not xhigh→max).
|
|
@@ -1156,6 +1478,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1156
1478
|
...(mcpServers ? { mcpServers } : {}),
|
|
1157
1479
|
...(resumeSessionId ? { resume: resumeSessionId } : {}),
|
|
1158
1480
|
...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
|
|
1481
|
+
spawnClaudeCodeProcess: spawnClaudeCodeWithDiagnostics,
|
|
1159
1482
|
...makeCliDebugOptions("provider"),
|
|
1160
1483
|
};
|
|
1161
1484
|
|
|
@@ -1163,6 +1486,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1163
1486
|
`model=${model.id} msgs=${context.messages.length} tools=${mcpTools.length}`,
|
|
1164
1487
|
`resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
|
|
1165
1488
|
`appendSys=${appendSystemPrompt} promptCtx=${promptContextAppend.labels.join(",") || "none"} strictMcp=${strictMcpConfigEnabled}`,
|
|
1489
|
+
`claudeExec=${claudeExecutablePreflight ? `${claudeExecutablePreflight.fileType}:${claudeExecutablePreflight.path}` : "sdk-default"}`,
|
|
1166
1490
|
`prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`);
|
|
1167
1491
|
|
|
1168
1492
|
// 3. Start SDK query and claim it for this context
|
|
@@ -1194,7 +1518,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1194
1518
|
}
|
|
1195
1519
|
|
|
1196
1520
|
// Background consumer — runs until query ends
|
|
1197
|
-
consumeQuery(sdkQuery, customToolNameToPi, model, () => wasAborted)
|
|
1521
|
+
consumeQuery(sdkQuery, customToolNameToPi, model, cwd, bridgeConfig, () => wasAborted)
|
|
1198
1522
|
.then(async ({ capturedSessionId }) => {
|
|
1199
1523
|
debug(`provider: consumeQuery completed, stopReason=${ctx().turnOutput?.stopReason}, error=${ctx().turnOutput?.errorMessage}, aborted=${wasAborted}`);
|
|
1200
1524
|
|
|
@@ -1243,7 +1567,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1243
1567
|
debug(`provider: continuation query, model=${model.id}, resume=${resumeId.slice(0, 8)}, prompt=${steerPrompt.slice(0, 60)}`);
|
|
1244
1568
|
|
|
1245
1569
|
try {
|
|
1246
|
-
const { capturedSessionId: contSid } = await consumeQuery(contQuery, customToolNameToPi, model, () => wasAborted);
|
|
1570
|
+
const { capturedSessionId: contSid } = await consumeQuery(contQuery, customToolNameToPi, model, cwd, bridgeConfig, () => wasAborted);
|
|
1247
1571
|
const sid = contSid ?? sharedSession?.sessionId;
|
|
1248
1572
|
if (sid) {
|
|
1249
1573
|
sharedSession = { sessionId: sid, cursor: sharedSession?.cursor ?? 0, cwd };
|
|
@@ -1264,6 +1588,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1264
1588
|
})
|
|
1265
1589
|
.catch((error) => {
|
|
1266
1590
|
debug(`provider: query error, model=${model.id}, aborted=${Boolean(options?.signal?.aborted)}, error=`, error);
|
|
1591
|
+
const openedExtraUsage = isExtraUsageRequiredMessage(error) && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "query error");
|
|
1267
1592
|
if ((wasAborted || options?.signal?.aborted) && sharedSession) {
|
|
1268
1593
|
sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
|
|
1269
1594
|
} else {
|
|
@@ -1272,7 +1597,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1272
1597
|
ctx().deferredUserMessages = [];
|
|
1273
1598
|
if (ctx().turnOutput) {
|
|
1274
1599
|
ctx().turnOutput.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
1275
|
-
ctx().turnOutput.errorMessage = error instanceof Error ? error.message : String(error)
|
|
1600
|
+
ctx().turnOutput.errorMessage = `${error instanceof Error ? error.message : String(error)}${openedExtraUsage ? "\n\nOpened Claude Code /extra-usage helper. Complete billing/admin flow in the browser, then retry the prompt." : ""}`;
|
|
1276
1601
|
}
|
|
1277
1602
|
ctx().currentPiStream?.push({ type: "error", reason: (ctx().turnOutput?.stopReason ?? "error") as "aborted" | "error", error: ctx().turnOutput! });
|
|
1278
1603
|
ctx().currentPiStream?.end();
|
|
@@ -1298,6 +1623,70 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1298
1623
|
return stream;
|
|
1299
1624
|
}
|
|
1300
1625
|
|
|
1626
|
+
function commandCwd(ctx: unknown): string {
|
|
1627
|
+
const value = (ctx as { cwd?: unknown })?.cwd;
|
|
1628
|
+
return typeof value === "string" && value.length > 0 ? value : process.cwd();
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
async function tryOpenExtensionManagerSettings(ctx: { ui: ExtensionUIContext }): Promise<boolean> {
|
|
1632
|
+
const host = globalThis as unknown as Record<PropertyKey, unknown>;
|
|
1633
|
+
const openQuickSettings = host[Symbol.for("vstack.pi.extension-manager.open-quick-settings")];
|
|
1634
|
+
if (typeof openQuickSettings !== "function") return false;
|
|
1635
|
+
try {
|
|
1636
|
+
await (openQuickSettings as (ctx: unknown, hint?: string) => Promise<void>)(ctx, "@vanillagreen/pi-claude-bridge");
|
|
1637
|
+
return true;
|
|
1638
|
+
} catch {
|
|
1639
|
+
return false;
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
function showBridgeStatus(ctx: { ui: ExtensionUIContext; cwd?: string }): void {
|
|
1644
|
+
const config = loadConfig(commandCwd(ctx));
|
|
1645
|
+
ctx.ui.notify([
|
|
1646
|
+
`Claude bridge: ${config.enabled === false ? "disabled" : "enabled"}`,
|
|
1647
|
+
`Extra usage auto-helper: ${extraUsageAllowed(config) ? "on" : "off"} (settings)`,
|
|
1648
|
+
`Use /claude-bridge:extra to run Claude Code /extra-usage now.`,
|
|
1649
|
+
].join("\n"), "info");
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
function registerBridgeCommands(pi: ExtensionAPI): void {
|
|
1653
|
+
const guard = pi as unknown as Record<PropertyKey, unknown>;
|
|
1654
|
+
if (guard[COMMANDS_REGISTERED_KEY]) return;
|
|
1655
|
+
guard[COMMANDS_REGISTERED_KEY] = true;
|
|
1656
|
+
|
|
1657
|
+
const runExtraUsage = async (ctx: { ui: ExtensionUIContext; cwd?: string }) => {
|
|
1658
|
+
const cwd = commandCwd(ctx);
|
|
1659
|
+
if (extraUsageHelperInFlight) {
|
|
1660
|
+
ctx.ui.notify("Claude extra usage helper already running.", "info");
|
|
1661
|
+
await extraUsageHelperInFlight.catch(() => undefined);
|
|
1662
|
+
return;
|
|
1663
|
+
}
|
|
1664
|
+
try {
|
|
1665
|
+
ctx.ui.notify("Claude extra usage helper starting…", "info");
|
|
1666
|
+
extraUsageHelperInFlight = runExtraUsageHelper(cwd)
|
|
1667
|
+
.finally(() => { extraUsageHelperInFlight = null; });
|
|
1668
|
+
const message = await extraUsageHelperInFlight;
|
|
1669
|
+
ctx.ui.notify(`Claude extra usage helper: ${message}`, "info");
|
|
1670
|
+
} catch (error) {
|
|
1671
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1672
|
+
ctx.ui.notify(`Claude extra usage helper failed: ${message}`, "error");
|
|
1673
|
+
}
|
|
1674
|
+
};
|
|
1675
|
+
|
|
1676
|
+
pi.registerCommand("claude-bridge", {
|
|
1677
|
+
description: "Open Claude bridge settings/status",
|
|
1678
|
+
handler: async (args: string, ctx) => {
|
|
1679
|
+
if (args.trim()) ctx.ui.notify("Unknown /claude-bridge argument. Use /claude-bridge:extra to run Claude Code /extra-usage.", "warning");
|
|
1680
|
+
if (await tryOpenExtensionManagerSettings(ctx)) return;
|
|
1681
|
+
showBridgeStatus(ctx);
|
|
1682
|
+
},
|
|
1683
|
+
});
|
|
1684
|
+
pi.registerCommand("claude-bridge:extra", {
|
|
1685
|
+
description: "Run Claude Code /extra-usage through claude-bridge",
|
|
1686
|
+
handler: async (_args: string, ctx) => runExtraUsage(ctx),
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1301
1690
|
// --- Extension registration ---
|
|
1302
1691
|
|
|
1303
1692
|
export default function (pi: ExtensionAPI) {
|
|
@@ -1307,6 +1696,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1307
1696
|
|
|
1308
1697
|
const config = loadConfig(process.cwd());
|
|
1309
1698
|
debug("loadConfig:", JSON.stringify(config));
|
|
1699
|
+
registerBridgeCommands(pi);
|
|
1310
1700
|
if (config.enabled === false) {
|
|
1311
1701
|
debug("provider: disabled by configuration");
|
|
1312
1702
|
return;
|