@vanillagreen/pi-claude-bridge 1.0.5 → 1.1.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 +2 -2
- package/bundle/index.js +1329 -415
- package/package.json +6 -6
- package/src/convert.ts +44 -6
- package/src/index.ts +110 -8
- package/src/query-state.ts +1 -1
- package/src/typebox-to-zod.ts +19 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vanillagreen/pi-claude-bridge",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.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": [
|
|
@@ -104,19 +104,19 @@
|
|
|
104
104
|
"change-case": "^5.4.4"
|
|
105
105
|
},
|
|
106
106
|
"peerDependencies": {
|
|
107
|
-
"@
|
|
108
|
-
"@
|
|
107
|
+
"@earendil-works/pi-ai": "*",
|
|
108
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
109
109
|
},
|
|
110
110
|
"devDependencies": {
|
|
111
|
-
"@
|
|
112
|
-
"@
|
|
111
|
+
"@earendil-works/pi-ai": "^0.74.0",
|
|
112
|
+
"@earendil-works/pi-coding-agent": "^0.74.0",
|
|
113
113
|
"@types/node": "^24.3.0",
|
|
114
114
|
"esbuild": "^0.28.0",
|
|
115
115
|
"tsx": "^4.21.0",
|
|
116
116
|
"typescript": "^6.0.3"
|
|
117
117
|
},
|
|
118
118
|
"scripts": {
|
|
119
|
-
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node20 --outfile=bundle/index.js --external:@
|
|
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",
|
|
120
120
|
"prepack": "npm run build",
|
|
121
121
|
"test:unit": "node --import tsx --test tests/unit-*.mjs",
|
|
122
122
|
"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",
|
package/src/convert.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Pure pi→Anthropic message conversion helpers.
|
|
2
2
|
// Extracted so they can be tested without pulling in the full extension runtime.
|
|
3
3
|
|
|
4
|
-
import type { Message as PiMessage } from "@
|
|
5
|
-
import type { Message as SessionMessage } from "cc-session-io";
|
|
4
|
+
import type { Message as PiMessage } from "@earendil-works/pi-ai";
|
|
5
|
+
import type { ContentBlock, Message as SessionMessage } from "cc-session-io";
|
|
6
6
|
import { pascalCase } from "change-case";
|
|
7
7
|
|
|
8
8
|
export const PROVIDER_ID = "claude-bridge";
|
|
@@ -44,6 +44,42 @@ export function messageContentToText(
|
|
|
44
44
|
return hasText ? parts.join("\n") : "";
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
function imageBlockToAnthropic(block: { data?: string; mimeType?: string }): ContentBlock | undefined {
|
|
48
|
+
if (!block.data || !block.mimeType) return undefined;
|
|
49
|
+
return { type: "image", source: { type: "base64", media_type: block.mimeType, data: block.data } } as ContentBlock;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function toolResultContentToAnthropic(
|
|
53
|
+
content: string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>,
|
|
54
|
+
): string | ContentBlock[] {
|
|
55
|
+
if (typeof content === "string") return content;
|
|
56
|
+
if (!Array.isArray(content)) return "";
|
|
57
|
+
const blocks: ContentBlock[] = [];
|
|
58
|
+
for (const block of content) {
|
|
59
|
+
if (block.type === "text" && block.text) {
|
|
60
|
+
blocks.push({ type: "text", text: block.text });
|
|
61
|
+
} else if (block.type === "image") {
|
|
62
|
+
const image = imageBlockToAnthropic(block);
|
|
63
|
+
if (image) blocks.push(image);
|
|
64
|
+
} else if (block.type) {
|
|
65
|
+
blocks.push({ type: "text", text: `[${block.type}]` });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (blocks.length === 0) return "";
|
|
69
|
+
if (blocks.every((block) => block.type === "text")) return blocks.map((block) => (block as { text: string }).text).join("\n");
|
|
70
|
+
return blocks;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function assistantProvenancePrefix(msg: PiMessage): string | undefined {
|
|
74
|
+
if (msg.role !== "assistant") return undefined;
|
|
75
|
+
const provider = typeof (msg as any).provider === "string" ? (msg as any).provider : undefined;
|
|
76
|
+
const model = typeof (msg as any).model === "string" ? (msg as any).model : undefined;
|
|
77
|
+
const api = typeof (msg as any).api === "string" ? (msg as any).api : undefined;
|
|
78
|
+
if (!provider && !model && !api) return undefined;
|
|
79
|
+
if (provider === PROVIDER_ID || api === "anthropic") return undefined;
|
|
80
|
+
return `[Prior Pi assistant response from ${provider ?? api ?? "unknown-provider"}${model ? `/${model}` : ""}]\n`;
|
|
81
|
+
}
|
|
82
|
+
|
|
47
83
|
/** Convert pi message array to Anthropic API format. */
|
|
48
84
|
export function convertPiMessages(
|
|
49
85
|
messages: PiMessage[],
|
|
@@ -61,16 +97,18 @@ export function convertPiMessages(
|
|
|
61
97
|
for (const block of msg.content) {
|
|
62
98
|
if (block.type === "text" && block.text) parts.push({ type: "text", text: block.text });
|
|
63
99
|
else if (block.type === "image" && block.data && block.mimeType) {
|
|
64
|
-
parts.push(
|
|
100
|
+
parts.push(imageBlockToAnthropic(block));
|
|
65
101
|
}
|
|
66
102
|
}
|
|
67
|
-
anthropicMessages.push({ role: "user", content: parts.length ? parts : "[image]" });
|
|
103
|
+
anthropicMessages.push({ role: "user", content: parts.filter(Boolean).length ? parts.filter(Boolean) as ContentBlock[] : "[image]" });
|
|
68
104
|
} else {
|
|
69
105
|
anthropicMessages.push({ role: "user", content: "[empty]" });
|
|
70
106
|
}
|
|
71
107
|
} else if (msg.role === "assistant") {
|
|
72
108
|
const content = Array.isArray(msg.content) ? msg.content : [];
|
|
73
109
|
const blocks = [];
|
|
110
|
+
const provenance = assistantProvenancePrefix(msg);
|
|
111
|
+
if (provenance) blocks.push({ type: "text", text: provenance });
|
|
74
112
|
for (const block of content) {
|
|
75
113
|
if (block.type === "text" && block.text) {
|
|
76
114
|
blocks.push({ type: "text", text: block.text });
|
|
@@ -88,10 +126,10 @@ export function convertPiMessages(
|
|
|
88
126
|
if (!blocks.length) blocks.push({ type: "text", text: "[incompatible content omitted]" });
|
|
89
127
|
anthropicMessages.push({ role: "assistant", content: blocks });
|
|
90
128
|
} else if (msg.role === "toolResult") {
|
|
91
|
-
const
|
|
129
|
+
const content = toolResultContentToAnthropic(msg.content);
|
|
92
130
|
anthropicMessages.push({
|
|
93
131
|
role: "user",
|
|
94
|
-
content: [{ type: "tool_result", tool_use_id: sanitizeToolId(msg.toolCallId, sanitizedIds), content:
|
|
132
|
+
content: [{ type: "tool_result", tool_use_id: sanitizeToolId(msg.toolCallId, sanitizedIds), content: content || "", is_error: msg.isError }],
|
|
95
133
|
});
|
|
96
134
|
}
|
|
97
135
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { calculateCost, getModels, type AssistantMessage, type AssistantMessageEventStream, type Context, type Model, type SimpleStreamOptions, type Tool } from "@
|
|
2
|
-
import * as piAi from "@
|
|
3
|
-
import { type ExtensionAPI, type ExtensionUIContext } from "@
|
|
1
|
+
import { calculateCost, getModels, type AssistantMessage, type AssistantMessageEventStream, type Context, type Model, type SimpleStreamOptions, type Tool } from "@earendil-works/pi-ai";
|
|
2
|
+
import * as piAi from "@earendil-works/pi-ai";
|
|
3
|
+
import { type ExtensionAPI, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { createSdkMcpServer, query, type EffortLevel, type SDKMessage, type SDKUserMessage, type SettingSource } from "@anthropic-ai/claude-agent-sdk";
|
|
5
5
|
import type { Base64ImageSource, ContentBlockParam, MessageParam } from "@anthropic-ai/sdk/resources";
|
|
6
|
-
import { createSession, deleteSession, repairToolPairing } from "cc-session-io";
|
|
6
|
+
import { createSession, deleteSession, openSession, repairToolPairing } from "cc-session-io";
|
|
7
|
+
import { createHash } from "crypto";
|
|
7
8
|
import { accessSync, appendFileSync, constants as fsConstants, mkdirSync, realpathSync, statSync } from "fs";
|
|
8
9
|
import { homedir } from "os";
|
|
9
10
|
import { delimiter, dirname, join } from "path";
|
|
@@ -184,12 +185,107 @@ interface SessionState {
|
|
|
184
185
|
}
|
|
185
186
|
|
|
186
187
|
let sharedSession: SessionState | null = null;
|
|
188
|
+
let extensionApi: ExtensionAPI | undefined;
|
|
189
|
+
|
|
190
|
+
const BRIDGE_SESSION_CUSTOM_TYPE = "claude-bridge-session";
|
|
191
|
+
|
|
192
|
+
interface PersistedBridgeSessionState extends SessionState {
|
|
193
|
+
fingerprint: string;
|
|
194
|
+
piSessionId?: string;
|
|
195
|
+
updatedAt: string;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function fingerprintMessages(messages: Context["messages"]): string {
|
|
199
|
+
const normalized = messages.map((message) => {
|
|
200
|
+
if (message.role === "assistant") {
|
|
201
|
+
return {
|
|
202
|
+
role: message.role,
|
|
203
|
+
provider: (message as AssistantMessage).provider,
|
|
204
|
+
model: (message as AssistantMessage).model,
|
|
205
|
+
content: (message as AssistantMessage).content,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
return message;
|
|
209
|
+
});
|
|
210
|
+
return createHash("sha256").update(JSON.stringify(normalized)).digest("hex");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function readBuiltSessionContext(sessionManager: unknown): { messages: Context["messages"] } | undefined {
|
|
214
|
+
const built = typeof (sessionManager as any)?.buildSessionContext === "function" ? (sessionManager as any).buildSessionContext() : undefined;
|
|
215
|
+
return Array.isArray(built?.messages) ? built as { messages: Context["messages"] } : undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function latestPersistedBridgeSession(sessionManager: unknown): PersistedBridgeSessionState | undefined {
|
|
219
|
+
const entries = typeof (sessionManager as any)?.getEntries === "function" ? (sessionManager as any).getEntries() : [];
|
|
220
|
+
if (!Array.isArray(entries)) return undefined;
|
|
221
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
222
|
+
const entry = entries[i];
|
|
223
|
+
if (entry?.type !== "custom" || entry.customType !== BRIDGE_SESSION_CUSTOM_TYPE) continue;
|
|
224
|
+
const data = entry.data as Partial<PersistedBridgeSessionState> | undefined;
|
|
225
|
+
if (!data || typeof data.sessionId !== "string" || typeof data.cursor !== "number" || typeof data.cwd !== "string" || typeof data.fingerprint !== "string") continue;
|
|
226
|
+
return data as PersistedBridgeSessionState;
|
|
227
|
+
}
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function claudeSessionExists(sessionId: string, cwd: string): boolean {
|
|
232
|
+
try {
|
|
233
|
+
const session = openSession({ sessionId, projectPath: cwd, claudeDir: process.env.CLAUDE_CONFIG_DIR });
|
|
234
|
+
statSync(session.jsonlPath);
|
|
235
|
+
return true;
|
|
236
|
+
} catch {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function restoreSharedSessionFromPi(ctx: { sessionManager?: unknown }): void {
|
|
242
|
+
const persisted = latestPersistedBridgeSession(ctx.sessionManager);
|
|
243
|
+
if (!persisted) return;
|
|
244
|
+
const built = readBuiltSessionContext(ctx.sessionManager);
|
|
245
|
+
if (!built) return;
|
|
246
|
+
const cursor = Math.max(0, Math.min(persisted.cursor, built.messages.length));
|
|
247
|
+
const fingerprint = fingerprintMessages(built.messages.slice(0, cursor));
|
|
248
|
+
if (fingerprint !== persisted.fingerprint) {
|
|
249
|
+
debug(`restoreSharedSession: fingerprint mismatch for ${persisted.sessionId.slice(0, 8)}`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (!claudeSessionExists(persisted.sessionId, persisted.cwd)) {
|
|
253
|
+
debug(`restoreSharedSession: Claude session missing for ${persisted.sessionId.slice(0, 8)}`);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
sharedSession = { sessionId: persisted.sessionId, cursor, cwd: persisted.cwd };
|
|
257
|
+
debug(`restoreSharedSession: restored ${persisted.sessionId.slice(0, 8)}, cursor=${cursor}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function schedulePersistSharedSession(ctxLike?: { sessionManager?: unknown }): void {
|
|
261
|
+
if (!extensionApi || !sharedSession || !ctxLike?.sessionManager) return;
|
|
262
|
+
const snapshot = { ...sharedSession };
|
|
263
|
+
const timer = setTimeout(() => {
|
|
264
|
+
try {
|
|
265
|
+
const built = readBuiltSessionContext(ctxLike.sessionManager);
|
|
266
|
+
if (!built) return;
|
|
267
|
+
const cursor = Math.max(0, Math.min(snapshot.cursor, built.messages.length));
|
|
268
|
+
const data: PersistedBridgeSessionState = {
|
|
269
|
+
...snapshot,
|
|
270
|
+
cursor,
|
|
271
|
+
fingerprint: fingerprintMessages(built.messages.slice(0, cursor)),
|
|
272
|
+
piSessionId: typeof (ctxLike.sessionManager as any)?.getSessionId === "function" ? (ctxLike.sessionManager as any).getSessionId() : undefined,
|
|
273
|
+
updatedAt: new Date().toISOString(),
|
|
274
|
+
};
|
|
275
|
+
extensionApi?.appendEntry(BRIDGE_SESSION_CUSTOM_TYPE, data);
|
|
276
|
+
debug(`persistSharedSession: saved ${data.sessionId.slice(0, 8)}, cursor=${data.cursor}`);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
debug("persistSharedSession failed:", error);
|
|
279
|
+
}
|
|
280
|
+
}, 0);
|
|
281
|
+
timer.unref?.();
|
|
282
|
+
}
|
|
187
283
|
|
|
188
284
|
// Convert pi messages to Anthropic API format for session import.
|
|
189
|
-
// Lossy: non-Anthropic thinking blocks are dropped (no valid signature)
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
285
|
+
// Lossy: non-Anthropic thinking blocks are dropped (no valid signature). User and
|
|
286
|
+
// tool-result image blocks are preserved when possible. If assistant blocks are
|
|
287
|
+
// otherwise incompatible, convertPiMessages emits a text placeholder so the record
|
|
288
|
+
// sequence stays valid before repairToolPairing runs.
|
|
193
289
|
function convertAndImportMessages(
|
|
194
290
|
session: ReturnType<typeof createSession>,
|
|
195
291
|
messages: Context["messages"],
|
|
@@ -1166,6 +1262,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1166
1262
|
// --- Extension registration ---
|
|
1167
1263
|
|
|
1168
1264
|
export default function (pi: ExtensionAPI) {
|
|
1265
|
+
extensionApi = pi;
|
|
1169
1266
|
// Disable non-essential Claude Code traffic (update checks, MCP registry, telemetry)
|
|
1170
1267
|
process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
|
1171
1268
|
|
|
@@ -1195,8 +1292,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
1195
1292
|
if (event.reason === "new" || event.reason === "resume" || event.reason === "fork") {
|
|
1196
1293
|
clearSession(`session_start:${event.reason}`);
|
|
1197
1294
|
}
|
|
1295
|
+
if (event.reason === "startup" || event.reason === "resume" || event.reason === "fork") restoreSharedSessionFromPi(ctx);
|
|
1198
1296
|
});
|
|
1199
1297
|
pi.on("session_shutdown", () => clearSession("session_shutdown"));
|
|
1298
|
+
pi.on("message_end", (event, ctx) => {
|
|
1299
|
+
const message = (event as { message?: AssistantMessage }).message;
|
|
1300
|
+
if (message?.role === "assistant" && message.provider === PROVIDER_ID) schedulePersistSharedSession(ctx);
|
|
1301
|
+
});
|
|
1200
1302
|
|
|
1201
1303
|
// pi /compact and session-tree navigation (rewind / fork-at-point /
|
|
1202
1304
|
// branch switch) both mutate pi's messages array out from under the
|
package/src/query-state.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Extracted from index.ts so tests can import without activating the extension.
|
|
8
8
|
|
|
9
|
-
import type { AssistantMessage, AssistantMessageEventStream, Model } from "@
|
|
9
|
+
import type { AssistantMessage, AssistantMessageEventStream, Model } from "@earendil-works/pi-ai";
|
|
10
10
|
import type { McpResult } from "./extract-tool-results.js";
|
|
11
11
|
|
|
12
12
|
export interface PendingToolCall {
|
package/src/typebox-to-zod.ts
CHANGED
|
@@ -13,15 +13,29 @@ import { z } from "zod";
|
|
|
13
13
|
|
|
14
14
|
export function jsonSchemaPropertyToZod(prop: Record<string, unknown>): z.ZodTypeAny {
|
|
15
15
|
let base: z.ZodTypeAny;
|
|
16
|
-
if (Array.isArray(prop.enum)) base = z.enum(prop.enum as [string, ...string[]]);
|
|
16
|
+
if (Array.isArray(prop.enum) && prop.enum.length > 0) base = z.enum(prop.enum as [string, ...string[]]);
|
|
17
17
|
else switch (prop.type) {
|
|
18
18
|
case "string": base = z.string(); break;
|
|
19
19
|
case "number": case "integer": base = z.number(); break;
|
|
20
20
|
case "boolean": base = z.boolean(); break;
|
|
21
|
-
case "array":
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
case "array": {
|
|
22
|
+
base = prop.items
|
|
23
|
+
? z.array(jsonSchemaPropertyToZod(prop.items as Record<string, unknown>))
|
|
24
|
+
: z.array(z.unknown());
|
|
25
|
+
const minItems = typeof prop.minItems === "number" ? prop.minItems : undefined;
|
|
26
|
+
if (minItems !== undefined) base = (base as z.ZodArray<z.ZodTypeAny>).min(minItems);
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
case "object": {
|
|
30
|
+
if (prop.properties && typeof prop.properties === "object" && !Array.isArray(prop.properties)) {
|
|
31
|
+
base = z.object(jsonSchemaToZodShape(prop));
|
|
32
|
+
if (prop.additionalProperties === false) base = (base as z.ZodObject<Record<string, z.ZodTypeAny>>).strict();
|
|
33
|
+
else if (prop.additionalProperties === true) base = (base as z.ZodObject<Record<string, z.ZodTypeAny>>).passthrough();
|
|
34
|
+
} else {
|
|
35
|
+
base = z.record(z.string(), z.unknown());
|
|
36
|
+
}
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
25
39
|
default: base = z.unknown();
|
|
26
40
|
}
|
|
27
41
|
if (typeof prop.description === "string") base = base.describe(prop.description);
|