@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillagreen/pi-claude-bridge",
3
- "version": "1.1.3",
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.128",
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.74.0",
112
- "@earendil-works/pi-coding-agent": "^0.74.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=node20 --outfile=bundle/index.js --external:@earendil-works/pi-ai --external:@earendil-works/pi-coding-agent",
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": ">=20"
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
- piUI?.notify(`Claude rate limited (${info.rateLimitType ?? "unknown"}) resets at ${resetsAt}`, "warning");
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;