@vanillagreen/pi-claude-bridge 1.1.4 → 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.4",
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"
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
@@ -16,7 +16,7 @@ import { MCP_SERVER_NAME, MCP_TOOL_PREFIX, extractSkillsBlock } from "./skills.j
16
16
  import { verifyWrittenSession as _verifyWrittenSession } from "./session-verify.js";
17
17
  import { extractAllToolResults as _extractAllToolResults, type McpResult } from "./extract-tool-results.js";
18
18
  import { QueryContext, ctx, stackDepth, pushContext, popContext } from "./query-state.js";
19
- import { loadConfig } from "./config.js";
19
+ import { loadConfig, type Config } from "./config.js";
20
20
  import { extractAgentsAppend } from "./agents-md.js";
21
21
  import { buildPromptContextAppend } from "./prompt-context.js";
22
22
  import { jsonSchemaToZodShape } from "./typebox-to-zod.js";
@@ -365,6 +365,7 @@ function diagDump(label: string, data: Record<string, unknown>) {
365
365
  // On session_shutdown (including /reload), clearSession() resets this so a fresh
366
366
  // registration can occur for the next session.
367
367
  const ACTIVE_STREAM_SIMPLE_KEY = Symbol.for("claude-bridge:activeStreamSimple");
368
+ const COMMANDS_REGISTERED_KEY = Symbol.for("claude-bridge:commandsRegistered");
368
369
 
369
370
  const SDK_TO_PI_TOOL_NAME: Record<string, string> = {
370
371
  read: "read", write: "write", edit: "edit", bash: "bash",
@@ -422,6 +423,82 @@ interface SessionState {
422
423
 
423
424
  let sharedSession: SessionState | null = null;
424
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
+ }
425
502
 
426
503
  const BRIDGE_SESSION_CUSTOM_TYPE = "claude-bridge-session";
427
504
 
@@ -851,9 +928,6 @@ function mapToolArgs(
851
928
  // them without activating the extension. `ctx()`, `pushContext()`, `popContext()`
852
929
  // are imported at the top of this file.
853
930
 
854
- // Global (not query state):
855
- let piUI: ExtensionUIContext | null = null;
856
-
857
931
  function resolveMcpTools(context: Context, excludeToolName?: string): {
858
932
  mcpTools: Tool[];
859
933
  customToolNameToSdk: Map<string, string>;
@@ -1156,6 +1230,8 @@ async function consumeQuery(
1156
1230
  sdkQuery: ReturnType<typeof query>,
1157
1231
  customToolNameToPi: Map<string, string>,
1158
1232
  model: Model<any>,
1233
+ cwd: string,
1234
+ bridgeConfig: Config,
1159
1235
  wasAborted: () => boolean,
1160
1236
  ): Promise<{ capturedSessionId?: string }> {
1161
1237
  let capturedSessionId: string | undefined;
@@ -1180,6 +1256,14 @@ async function consumeQuery(
1180
1256
  ctx().currentPiStream?.push({ type: "text_start", contentIndex: idx, partial: ctx().turnOutput });
1181
1257
  ctx().currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: text, partial: ctx().turnOutput });
1182
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;
1183
1267
  }
1184
1268
  break;
1185
1269
  case "system":
@@ -1194,7 +1278,9 @@ async function consumeQuery(
1194
1278
  debug("consumeQuery: rate_limit_event", JSON.stringify(info).slice(0, 300));
1195
1279
  if (info?.status === "rejected") {
1196
1280
  const resetsAt = info.resetsAt ? new Date(info.resetsAt).toLocaleTimeString() : "unknown";
1197
- 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");
1198
1284
  } else if (info?.status === "allowed_warning") {
1199
1285
  piUI?.notify(`Claude rate limit warning: ${Math.round(info.utilization ?? 0)}% used (${info.rateLimitType ?? ""})`, "warning");
1200
1286
  }
@@ -1432,7 +1518,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1432
1518
  }
1433
1519
 
1434
1520
  // Background consumer — runs until query ends
1435
- consumeQuery(sdkQuery, customToolNameToPi, model, () => wasAborted)
1521
+ consumeQuery(sdkQuery, customToolNameToPi, model, cwd, bridgeConfig, () => wasAborted)
1436
1522
  .then(async ({ capturedSessionId }) => {
1437
1523
  debug(`provider: consumeQuery completed, stopReason=${ctx().turnOutput?.stopReason}, error=${ctx().turnOutput?.errorMessage}, aborted=${wasAborted}`);
1438
1524
 
@@ -1481,7 +1567,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1481
1567
  debug(`provider: continuation query, model=${model.id}, resume=${resumeId.slice(0, 8)}, prompt=${steerPrompt.slice(0, 60)}`);
1482
1568
 
1483
1569
  try {
1484
- const { capturedSessionId: contSid } = await consumeQuery(contQuery, customToolNameToPi, model, () => wasAborted);
1570
+ const { capturedSessionId: contSid } = await consumeQuery(contQuery, customToolNameToPi, model, cwd, bridgeConfig, () => wasAborted);
1485
1571
  const sid = contSid ?? sharedSession?.sessionId;
1486
1572
  if (sid) {
1487
1573
  sharedSession = { sessionId: sid, cursor: sharedSession?.cursor ?? 0, cwd };
@@ -1502,6 +1588,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1502
1588
  })
1503
1589
  .catch((error) => {
1504
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");
1505
1592
  if ((wasAborted || options?.signal?.aborted) && sharedSession) {
1506
1593
  sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
1507
1594
  } else {
@@ -1510,7 +1597,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1510
1597
  ctx().deferredUserMessages = [];
1511
1598
  if (ctx().turnOutput) {
1512
1599
  ctx().turnOutput.stopReason = options?.signal?.aborted ? "aborted" : "error";
1513
- 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." : ""}`;
1514
1601
  }
1515
1602
  ctx().currentPiStream?.push({ type: "error", reason: (ctx().turnOutput?.stopReason ?? "error") as "aborted" | "error", error: ctx().turnOutput! });
1516
1603
  ctx().currentPiStream?.end();
@@ -1536,6 +1623,70 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1536
1623
  return stream;
1537
1624
  }
1538
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
+
1539
1690
  // --- Extension registration ---
1540
1691
 
1541
1692
  export default function (pi: ExtensionAPI) {
@@ -1545,6 +1696,7 @@ export default function (pi: ExtensionAPI) {
1545
1696
 
1546
1697
  const config = loadConfig(process.cwd());
1547
1698
  debug("loadConfig:", JSON.stringify(config));
1699
+ registerBridgeCommands(pi);
1548
1700
  if (config.enabled === false) {
1549
1701
  debug("provider: disabled by configuration");
1550
1702
  return;