@vanillagreen/pi-claude-bridge 1.2.0 → 1.4.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.2.0",
3
+ "version": "1.4.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": [
@@ -94,6 +94,41 @@
94
94
  "category": "Claude Code",
95
95
  "apply": "live"
96
96
  },
97
+ {
98
+ "key": "fastMode",
99
+ "label": "Fast mode",
100
+ "description": "Enable Claude Code fast mode for bridge requests when the selected model supports it. Off by default.",
101
+ "type": "boolean",
102
+ "default": false,
103
+ "category": "Claude Code",
104
+ "apply": "live"
105
+ },
106
+ {
107
+ "key": "forceEffort",
108
+ "label": "Force Claude effort",
109
+ "description": "Override Pi's thinking-level mapping for all claude-bridge requests. Use max to reach Claude Code max effort even though Pi has no max thinking level. none keeps Pi's selected level.",
110
+ "type": "enum",
111
+ "enumValues": [
112
+ "none",
113
+ "low",
114
+ "medium",
115
+ "high",
116
+ "xhigh",
117
+ "max"
118
+ ],
119
+ "default": "none",
120
+ "category": "Claude Code",
121
+ "apply": "live"
122
+ },
123
+ {
124
+ "key": "modelEffortOverrides",
125
+ "label": "Model effort overrides",
126
+ "description": "Optional JSON object mapping claude-bridge model ids to Claude Code effort levels, e.g. {\"claude-opus-4-8\":\"max\"}. Keys may also use claude-bridge/<id> or *.",
127
+ "type": "string",
128
+ "default": "{}",
129
+ "category": "Claude Code",
130
+ "apply": "live"
131
+ },
97
132
  {
98
133
  "key": "pathToClaudeCodeExecutable",
99
134
  "label": "Claude executable path",
@@ -107,8 +142,8 @@
107
142
  }
108
143
  },
109
144
  "dependencies": {
110
- "@anthropic-ai/claude-agent-sdk": "0.2.141",
111
- "@anthropic-ai/sdk": "^0.73.0",
145
+ "@anthropic-ai/claude-agent-sdk": "0.3.158",
146
+ "@anthropic-ai/sdk": "0.93.0",
112
147
  "cc-session-io": "^0.3.1",
113
148
  "change-case": "^5.4.4"
114
149
  },
package/src/config.ts CHANGED
@@ -9,12 +9,22 @@ import { dirname, join, resolve } from "path";
9
9
 
10
10
  export const PACKAGE_ID = "@vanillagreen/pi-claude-bridge";
11
11
 
12
+ export type BridgeEffortLevel = "low" | "medium" | "high" | "xhigh" | "max";
13
+
14
+ const VALID_EFFORT_LEVELS = new Set<BridgeEffortLevel>(["low", "medium", "high", "xhigh", "max"]);
15
+
12
16
  export interface Config {
13
17
  enabled?: boolean;
14
18
  /** Low-level Claude Agent SDK plumbing. Most users won't need these. */
15
19
  provider?: {
16
20
  appendSystemPrompt?: boolean;
17
21
  allowExtraUsage?: boolean;
22
+ /** Enable Claude Code fast mode for bridge requests. */
23
+ fastMode?: boolean;
24
+ /** Force this Claude Code effort level for every bridge request. */
25
+ forceEffort?: BridgeEffortLevel;
26
+ /** Per-model Claude Code effort overrides keyed by model id (e.g. claude-opus-4-8). */
27
+ modelEffortOverrides?: Record<string, BridgeEffortLevel>;
18
28
  settingSources?: SettingSource[];
19
29
  strictMcpConfig?: boolean;
20
30
  pathToClaudeCodeExecutable?: string;
@@ -106,6 +116,53 @@ function stringFrom(raw: SettingsRecord, key: string): string | undefined {
106
116
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
107
117
  }
108
118
 
119
+ function hasOwn(raw: SettingsRecord, key: string): boolean {
120
+ return Object.prototype.hasOwnProperty.call(raw, key);
121
+ }
122
+
123
+ export function normalizeEffortLevel(value: unknown): BridgeEffortLevel | undefined {
124
+ if (typeof value !== "string") return undefined;
125
+ const normalized = value.trim().toLowerCase();
126
+ if (normalized === "" || normalized === "none" || normalized === "auto" || normalized === "default") return undefined;
127
+ return VALID_EFFORT_LEVELS.has(normalized as BridgeEffortLevel) ? normalized as BridgeEffortLevel : undefined;
128
+ }
129
+
130
+ export function normalizeModelEffortOverrides(value: unknown): Record<string, BridgeEffortLevel> | undefined {
131
+ let source: unknown = value;
132
+ if (typeof source === "string") {
133
+ const trimmed = source.trim();
134
+ if (!trimmed || trimmed === "{}") return undefined;
135
+ try {
136
+ source = JSON.parse(trimmed);
137
+ } catch {
138
+ return undefined;
139
+ }
140
+ }
141
+ const record = asRecord(source);
142
+ if (!record) return undefined;
143
+
144
+ const out: Record<string, BridgeEffortLevel> = {};
145
+ for (const [modelId, rawEffort] of Object.entries(record)) {
146
+ const key = modelId.trim();
147
+ const effort = normalizeEffortLevel(rawEffort);
148
+ if (key && effort) out[key] = effort;
149
+ }
150
+ return Object.keys(out).length > 0 ? out : undefined;
151
+ }
152
+
153
+ function normalizeProviderConfig(provider: Config["provider"] | undefined): Config["provider"] {
154
+ if (!provider) return {};
155
+ const raw = provider as SettingsRecord;
156
+ const out: Config["provider"] = { ...provider };
157
+ const forceEffort = normalizeEffortLevel(raw.forceEffort);
158
+ if (forceEffort) out.forceEffort = forceEffort;
159
+ else delete out.forceEffort;
160
+ const modelEffortOverrides = normalizeModelEffortOverrides(raw.modelEffortOverrides);
161
+ if (modelEffortOverrides) out.modelEffortOverrides = modelEffortOverrides;
162
+ else delete out.modelEffortOverrides;
163
+ return out;
164
+ }
165
+
109
166
  function managerToConfig(raw: SettingsRecord): Partial<Config> {
110
167
  const provider: Config["provider"] = {};
111
168
  const promptContext: Config["promptContext"] = {};
@@ -114,6 +171,14 @@ function managerToConfig(raw: SettingsRecord): Partial<Config> {
114
171
  if (appendSystemPrompt !== undefined) provider.appendSystemPrompt = appendSystemPrompt;
115
172
  const allowExtraUsage = boolFrom(raw, "allowExtraUsage");
116
173
  if (allowExtraUsage !== undefined) provider.allowExtraUsage = allowExtraUsage;
174
+ const fastMode = boolFrom(raw, "fastMode");
175
+ if (fastMode !== undefined) provider.fastMode = fastMode;
176
+ if (hasOwn(raw, "forceEffort")) {
177
+ provider.forceEffort = normalizeEffortLevel(raw.forceEffort);
178
+ }
179
+ if (hasOwn(raw, "modelEffortOverrides")) {
180
+ provider.modelEffortOverrides = normalizeModelEffortOverrides(raw.modelEffortOverrides);
181
+ }
117
182
  const strictMcpConfig = boolFrom(raw, "strictMcpConfig");
118
183
  if (strictMcpConfig !== undefined) provider.strictMcpConfig = strictMcpConfig;
119
184
  const claudePath = stringFrom(raw, "pathToClaudeCodeExecutable");
@@ -139,9 +204,10 @@ export function loadConfig(cwd: string): Config {
139
204
  const global = tryParseJson(join(piUserDir(), "claude-bridge.json"));
140
205
  const project = tryParseJson(join(cwd, ".pi", "claude-bridge.json"));
141
206
  const manager = managerToConfig(readManagerConfig(cwd));
207
+ const provider = normalizeProviderConfig({ ...global.provider, ...project.provider, ...manager.provider });
142
208
  return {
143
209
  enabled: manager.enabled ?? project.enabled ?? global.enabled ?? true,
144
- provider: { ...global.provider, ...project.provider, ...manager.provider },
210
+ provider,
145
211
  promptContext: { ...global.promptContext, ...project.promptContext, ...manager.promptContext },
146
212
  };
147
213
  }
package/src/convert.ts CHANGED
@@ -80,6 +80,34 @@ function assistantProvenancePrefix(msg: PiMessage): string | undefined {
80
80
  return `[Prior Pi assistant response from ${provider ?? api ?? "unknown-provider"}${model ? `/${model}` : ""}]\n`;
81
81
  }
82
82
 
83
+ function userMessageToAnthropic(msg: PiMessage): SessionMessage {
84
+ if (typeof msg.content === "string") return { role: "user", content: msg.content || "[empty]" };
85
+ if (Array.isArray(msg.content)) {
86
+ const parts = [];
87
+ for (const block of msg.content) {
88
+ if (block.type === "text" && block.text) parts.push({ type: "text", text: block.text });
89
+ else if (block.type === "image" && block.data && block.mimeType) parts.push(imageBlockToAnthropic(block));
90
+ }
91
+ const kept = parts.filter(Boolean) as ContentBlock[];
92
+ return { role: "user", content: kept.length ? kept : "[image]" };
93
+ }
94
+ return { role: "user", content: "[empty]" };
95
+ }
96
+
97
+ function toolResultToAnthropicBlock(msg: PiMessage, sanitizedIds: Map<string, string>): ContentBlock {
98
+ const content = toolResultContentToAnthropic(msg.content as string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>);
99
+ return {
100
+ type: "tool_result",
101
+ tool_use_id: sanitizeToolId((msg as { toolCallId: string }).toolCallId, sanitizedIds),
102
+ content: content || "",
103
+ is_error: (msg as { isError?: boolean }).isError,
104
+ } as ContentBlock;
105
+ }
106
+
107
+ function hasToolUse(msg: PiMessage): boolean {
108
+ return msg.role === "assistant" && Array.isArray(msg.content) && msg.content.some((block) => block.type === "toolCall");
109
+ }
110
+
83
111
  /** Convert pi message array to Anthropic API format. */
84
112
  export function convertPiMessages(
85
113
  messages: PiMessage[],
@@ -88,22 +116,26 @@ export function convertPiMessages(
88
116
  const anthropicMessages = [];
89
117
  const sanitizedIds = new Map();
90
118
 
91
- for (const msg of messages) {
119
+ const pushToolResultGroup = (toolMessages: PiMessage[]): void => {
120
+ if (toolMessages.length === 0) return;
121
+ anthropicMessages.push({
122
+ role: "user",
123
+ content: toolMessages.map((toolMsg) => {
124
+ const content = toolResultContentToAnthropic(toolMsg.content as string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>);
125
+ return {
126
+ type: "tool_result",
127
+ tool_use_id: sanitizeToolId((toolMsg as { toolCallId: string }).toolCallId, sanitizedIds),
128
+ content: content || "",
129
+ is_error: (toolMsg as { isError?: boolean }).isError,
130
+ };
131
+ }),
132
+ });
133
+ };
134
+
135
+ for (let i = 0; i < messages.length; i++) {
136
+ const msg = messages[i];
92
137
  if (msg.role === "user") {
93
- if (typeof msg.content === "string") {
94
- anthropicMessages.push({ role: "user", content: msg.content || "[empty]" });
95
- } else if (Array.isArray(msg.content)) {
96
- const parts = [];
97
- for (const block of msg.content) {
98
- if (block.type === "text" && block.text) parts.push({ type: "text", text: block.text });
99
- else if (block.type === "image" && block.data && block.mimeType) {
100
- parts.push(imageBlockToAnthropic(block));
101
- }
102
- }
103
- anthropicMessages.push({ role: "user", content: parts.filter(Boolean).length ? parts.filter(Boolean) as ContentBlock[] : "[image]" });
104
- } else {
105
- anthropicMessages.push({ role: "user", content: "[empty]" });
106
- }
138
+ anthropicMessages.push(userMessageToAnthropic(msg));
107
139
  } else if (msg.role === "assistant") {
108
140
  const content = Array.isArray(msg.content) ? msg.content : [];
109
141
  const blocks = [];
@@ -125,12 +157,37 @@ export function convertPiMessages(
125
157
  }
126
158
  if (!blocks.length) blocks.push({ type: "text", text: "[incompatible content omitted]" });
127
159
  anthropicMessages.push({ role: "assistant", content: blocks });
160
+
161
+ // Pi may inject steer/followUp user messages between parallel tool
162
+ // results, while runtime extraction treats every toolResult after the
163
+ // assistant (until the next assistant) as one turn. Claude history must
164
+ // put all tool_result blocks immediately after the tool_use assistant;
165
+ // replay interleaved user text only after that grouped result message.
166
+ if (hasToolUse(msg)) {
167
+ const toolMessages: PiMessage[] = [];
168
+ const interleavedUsers: PiMessage[] = [];
169
+ let j = i + 1;
170
+ for (; j < messages.length; j++) {
171
+ const next = messages[j];
172
+ if (next.role === "assistant") break;
173
+ if (next.role === "toolResult") toolMessages.push(next);
174
+ else if (next.role === "user") interleavedUsers.push(next);
175
+ else break;
176
+ }
177
+ if (toolMessages.length > 0) {
178
+ pushToolResultGroup(toolMessages);
179
+ for (const userMsg of interleavedUsers) anthropicMessages.push(userMessageToAnthropic(userMsg));
180
+ i = j - 1;
181
+ }
182
+ }
128
183
  } else if (msg.role === "toolResult") {
129
- const content = toolResultContentToAnthropic(msg.content);
130
- anthropicMessages.push({
131
- role: "user",
132
- content: [{ type: "tool_result", tool_use_id: sanitizeToolId(msg.toolCallId, sanitizedIds), content: content || "", is_error: msg.isError }],
133
- });
184
+ const blocks: ContentBlock[] = [];
185
+ for (; i < messages.length; i++) {
186
+ const toolMsg = messages[i];
187
+ if (toolMsg.role !== "toolResult") { i--; break; }
188
+ blocks.push(toolResultToAnthropicBlock(toolMsg, sanitizedIds));
189
+ }
190
+ anthropicMessages.push({ role: "user", content: blocks });
134
191
  }
135
192
  }
136
193