@viniciosrab/pi-claude-bridge 0.9.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/src/config.ts ADDED
@@ -0,0 +1,101 @@
1
+ // User-facing extension config. Loaded once at extension registration from
2
+ // the global agent dir (getAgentDir(), e.g. ~/.pi/agent/claude-bridge.json)
3
+ // and the project Pi config directory, project overriding global. Missing or
4
+ // unparseable files are ignored (error to console.error, empty object
5
+ // returned) so the extension always starts.
6
+
7
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
9
+ import { dirname, join } from "path";
10
+
11
+ export interface Config {
12
+ /** Date (YYYY-MM-DD) the one-time startup notice was shown. Written by the extension, not the user. */
13
+ startupNoticeShown?: string;
14
+ askClaude?: {
15
+ enabled?: boolean;
16
+ name?: string;
17
+ label?: string;
18
+ description?: string;
19
+ defaultMode?: "full" | "read" | "none";
20
+ defaultIsolated?: boolean;
21
+ allowFullMode?: boolean;
22
+ appendSkills?: boolean;
23
+ };
24
+ /** Low-level Claude Agent SDK plumbing. Most users won't need these. */
25
+ provider?: {
26
+ strictMcpConfig?: boolean;
27
+ autoMemoryEnabled?: boolean;
28
+ // Set to false to stop the provider's Claude Code subprocess from loading
29
+ // user/project/local settings (hooks, plugins, env, apiKeyHelper).
30
+ loadClaudeSettings?: boolean;
31
+ pathToClaudeCodeExecutable?: string;
32
+ // Subscription plan tier. Setting to "max" enables Opus 4.6 at 1M context
33
+ plan?: "pro" | "max";
34
+ // Set to true to opt into metered 1M context usage ("extra usage" in
35
+ // Anthropic billing). Enables Sonnet 4.6 [1m] on every plan and Opus 4.6
36
+ // [1m] on Pro.
37
+ longContextExtraUsage?: boolean;
38
+ // Model ids (e.g. "claude-future-9") whose declared 1M context Claude Code
39
+ // does not actually serve; pins them to the bare id at 200K.
40
+ forceTwoHundredK?: string[];
41
+ };
42
+ }
43
+
44
+ export function tryParseJson(path: string): Partial<Config> {
45
+ if (!existsSync(path)) return {};
46
+ try {
47
+ return JSON.parse(readFileSync(path, "utf-8"));
48
+ } catch (e) {
49
+ console.error(`claude-bridge: failed to parse ${path}: ${e}`);
50
+ return {};
51
+ }
52
+ }
53
+
54
+ export function claudeCodeSettings(provider: Config["provider"] = {}): { autoMemoryEnabled: boolean } {
55
+ return { autoMemoryEnabled: provider.autoMemoryEnabled ?? false };
56
+ }
57
+
58
+ /** Provider-path `settingSources` override. Absent keeps the SDK default (all sources);
59
+ * `loadClaudeSettings: false` passes an empty list so no settings files are loaded. */
60
+ export function providerSettingSourcesOption(provider: Config["provider"] = {}): { settingSources?: [] } {
61
+ return provider.loadClaudeSettings === false ? { settingSources: [] } : {};
62
+ }
63
+
64
+ export function globalConfigPath(): string {
65
+ return join(getAgentDir(), "claude-bridge.json");
66
+ }
67
+
68
+ /** Record today's date in the global config so the startup notice shows once, preserving every
69
+ * other field. Returns the config path for display either way.
70
+ *
71
+ * Parses directly rather than through tryParseJson, which reports an unparseable file as `{}`:
72
+ * spreading that would replace a user's whole config with just this marker the first time they
73
+ * leave a trailing comma in it. Losing the notice is the cheaper failure, so the write is
74
+ * skipped and the notice simply shows again next session. */
75
+ export function markStartupNoticeShown(): string {
76
+ const path = globalConfigPath();
77
+ let existing: Partial<Config> = {};
78
+ if (existsSync(path)) {
79
+ try {
80
+ existing = JSON.parse(readFileSync(path, "utf-8"));
81
+ } catch (e) {
82
+ console.error(`claude-bridge: leaving ${path} alone, it does not parse: ${e}`);
83
+ return path;
84
+ }
85
+ }
86
+ // en-CA renders YYYY-MM-DD in local time; toISOString() would report UTC.
87
+ const next = { ...existing, startupNoticeShown: new Date().toLocaleDateString("en-CA") };
88
+ mkdirSync(dirname(path), { recursive: true });
89
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
90
+ return path;
91
+ }
92
+
93
+ export function loadConfig(cwd: string): Config {
94
+ const global = tryParseJson(globalConfigPath());
95
+ const project = tryParseJson(join(cwd, CONFIG_DIR_NAME, "claude-bridge.json"));
96
+ return {
97
+ startupNoticeShown: project.startupNoticeShown ?? global.startupNoticeShown,
98
+ askClaude: { ...global.askClaude, ...project.askClaude },
99
+ provider: { ...global.provider, ...project.provider },
100
+ };
101
+ }
package/src/convert.ts ADDED
@@ -0,0 +1,222 @@
1
+ // Pure pi→Anthropic message conversion helpers.
2
+ // Extracted so they can be tested without pulling in the full extension runtime.
3
+
4
+ import type { Message as PiMessage } from "@earendil-works/pi-ai";
5
+ import type { Message as SessionMessage } from "cc-session-io";
6
+ import { pascalCase } from "change-case";
7
+ import { MCP_TOOL_PREFIX } from "./skills.js";
8
+
9
+ export const PROVIDER_ID = "claude-bridge";
10
+
11
+ // Pi tool names under Claude Code's builtin names. Only ever correct on the
12
+ // AskClaude path, where CC runs its own tools — see mapPiToolNameToSdk.
13
+ export const PI_TO_SDK_TOOL_NAME: Record<string, string> = {
14
+ read: "Read", write: "Write", edit: "Edit", bash: "Bash",
15
+ };
16
+
17
+ export function sanitizeToolId(id: string, cache: Map<string, string>): string {
18
+ const existing = cache.get(id);
19
+ if (existing) return existing;
20
+ const clean = id.replace(/[^a-zA-Z0-9_-]/g, "_");
21
+ cache.set(id, clean);
22
+ return clean;
23
+ }
24
+
25
+ /** A pi tool name as the name a rebuilt transcript has to call it by.
26
+ *
27
+ * Whether a map is passed is what distinguishes the two query shapes, because
28
+ * they need opposite answers:
29
+ *
30
+ * - **With a map — the provider path.** The query runs `tools: []`, so every
31
+ * tool Claude can call is a pi tool served over MCP, and its name is
32
+ * `mcp__custom-tools__<pi name>` by construction (resolveMcpTools). The map
33
+ * is consulted first only because it carries the served tool's exact casing.
34
+ * A name it lacks is a tool pi ran that we do not serve now — AskClaude,
35
+ * excluded on purpose, or an extension since disabled — and naming that after
36
+ * a Claude Code builtin would tell the model a builtin it cannot call is
37
+ * available and was already used. That is the prompt condition behind the
38
+ * phantom-call deadlock fixed in 122914dd, and the read direction refuses the
39
+ * same names for the same reason (piToolNameFor in index.ts).
40
+ * - **Without a map — the AskClaude path.** CC runs its own tools there, so
41
+ * builtin names are real, matching mapToolName in the other direction.
42
+ */
43
+ export function mapPiToolNameToSdk(name: string, customToolNameToSdk?: Map<string, string>): string {
44
+ if (!name) return "";
45
+ const normalized = name.toLowerCase();
46
+ // Pi history holds pi tool names. Our own SDK prefix can only reach here by
47
+ // feeding already-converted names back through the conversion, and prefixing
48
+ // twice invents a tool nobody serves.
49
+ if (normalized.startsWith(MCP_TOOL_PREFIX)) {
50
+ throw new Error(`mapPiToolNameToSdk: "${name}" is already an SDK tool name — pi history holds pi tool names`);
51
+ }
52
+ if (!customToolNameToSdk) return PI_TO_SDK_TOOL_NAME[normalized] ?? pascalCase(name);
53
+ return customToolNameToSdk.get(name) ?? customToolNameToSdk.get(normalized) ?? `${MCP_TOOL_PREFIX}${name}`;
54
+ }
55
+
56
+ export function messageContentToText(
57
+ content: string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>,
58
+ ): string {
59
+ if (typeof content === "string") return content;
60
+ if (!Array.isArray(content)) return "";
61
+ const parts = [];
62
+ let hasText = false;
63
+ for (const block of content) {
64
+ if (block.type === "text" && block.text) { parts.push(block.text); hasText = true; }
65
+ else if (block.type !== "text" && block.type !== "image") { parts.push(`[${block.type}]`); }
66
+ }
67
+ return hasText ? parts.join("\n") : "";
68
+ }
69
+
70
+ // Tool results are flattened to text, which is how Claude Code stores most of
71
+ // them. Images are the exception: they have no text form, so a result carrying
72
+ // one keeps the block array shape instead (also what CC writes for screenshots).
73
+ function toolResultContent(
74
+ content: string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>,
75
+ ): string | Array<Record<string, unknown>> {
76
+ if (typeof content === "string" || !Array.isArray(content)) return messageContentToText(content) || "";
77
+ const images = content.filter((b) => b.type === "image" && b.data && b.mimeType);
78
+ if (!images.length) return messageContentToText(content) || "";
79
+ const blocks: Array<Record<string, unknown>> = [];
80
+ for (const block of content) {
81
+ if (block.type === "text" && block.text) blocks.push({ type: "text", text: block.text });
82
+ else if (block.type === "image" && block.data && block.mimeType) {
83
+ blocks.push({ type: "image", source: { type: "base64", media_type: block.mimeType, data: block.data } });
84
+ } else if (block.type !== "text" && block.type !== "image") {
85
+ // Same marker messageContentToText leaves for unrecognized blocks, so the
86
+ // text and image paths describe an extension's output the same way.
87
+ blocks.push({ type: "text", text: `[${block.type}]` });
88
+ }
89
+ }
90
+ return blocks;
91
+ }
92
+
93
+ /** What convertPiMessages discarded, for the debug line in index.ts. */
94
+ export type DroppedContent = {
95
+ thinking: number;
96
+ abortedTurns: number;
97
+ providers: Set<string>;
98
+ other: Map<string, number>;
99
+ };
100
+
101
+ /** Convert pi message array to Anthropic API format. */
102
+ export function convertPiMessages(
103
+ messages: PiMessage[],
104
+ customToolNameToSdk?: Map<string, string>,
105
+ ): { anthropicMessages: SessionMessage[]; sanitizedIds: Map<string, string>; dropped: DroppedContent } {
106
+ const anthropicMessages = [];
107
+ const sanitizedIds = new Map();
108
+ // What conversion discarded. Nothing downstream can tell: a stripped thinking
109
+ // block and a message that never carried one convert to the same thing, so
110
+ // without this the loss is invisible in the log and in a captured request.
111
+ const dropped: DroppedContent = { thinking: 0, abortedTurns: 0, providers: new Set(), other: new Map() };
112
+ // The user message collecting this assistant turn's tool results, if one has
113
+ // been emitted yet, and the index of the assistant message it belongs to. Both
114
+ // are cleared at every assistant message — see the toolResult branch.
115
+ let turnResults: { role: "user"; content: Array<Record<string, unknown>> } | null = null;
116
+ let turnAssistantIdx: number | null = null;
117
+
118
+ for (const msg of messages) {
119
+ if (msg.role === "user") {
120
+ if (typeof msg.content === "string") {
121
+ anthropicMessages.push({ role: "user", content: msg.content || "[empty]" });
122
+ } else if (Array.isArray(msg.content)) {
123
+ const parts = [];
124
+ for (const block of msg.content) {
125
+ if (block.type === "text" && block.text) parts.push({ type: "text", text: block.text });
126
+ else if (block.type === "image" && block.data && block.mimeType) {
127
+ parts.push({ type: "image", source: { type: "base64", media_type: block.mimeType, data: block.data } });
128
+ }
129
+ }
130
+ anthropicMessages.push({ role: "user", content: parts.length ? parts : "[image]" });
131
+ } else {
132
+ anthropicMessages.push({ role: "user", content: "[empty]" });
133
+ }
134
+ } else if (msg.role === "assistant") {
135
+ const content = Array.isArray(msg.content) ? msg.content : [];
136
+ const blocks = [];
137
+ for (const block of content) {
138
+ if (block.type === "text" && block.text) {
139
+ blocks.push({ type: "text", text: block.text });
140
+ } else if (block.type === "thinking") {
141
+ // Only replay thinking Claude Code itself produced. A signature minted
142
+ // by any other provider — including pi's own Anthropic provider — is
143
+ // not ours to hand back, and Anthropic rejects ones it can't verify.
144
+ const sig = block.thinkingSignature;
145
+ if (msg.provider === PROVIDER_ID && sig) {
146
+ blocks.push({ type: "thinking", thinking: block.thinking ?? "", signature: sig });
147
+ } else {
148
+ dropped.thinking++;
149
+ dropped.providers.add(msg.provider ?? "unknown");
150
+ }
151
+ } else if (block.type === "toolCall") {
152
+ const toolName = mapPiToolNameToSdk(block.name, customToolNameToSdk);
153
+ blocks.push({ type: "tool_use", id: sanitizeToolId(block.id, sanitizedIds), name: toolName, input: block.arguments ?? {} });
154
+ } else {
155
+ dropped.other.set(block.type, (dropped.other.get(block.type) ?? 0) + 1);
156
+ }
157
+ }
158
+ // A turn the user aborted before anything streamed carries no content at
159
+ // all. Standing a placeholder in its place invents a reply the assistant
160
+ // never made, and because it lands early in the prefix it costs the whole
161
+ // downstream prompt cache every time the session is rebuilt. Drop it:
162
+ // Session.importMessages imposes no alternation, and a turn with no blocks
163
+ // has no tool_use ids needing a synthetic result. Left before the turn
164
+ // bookkeeping so a stray result still attaches to the last assistant
165
+ // message actually emitted.
166
+ //
167
+ // Do NOT clear turnResults/turnAssistantIdx here. It looks like the tidy
168
+ // thing to do, but an abort between two parallel results — assistant[X,Y],
169
+ // R_X, aborted turn, R_Y — would then start a second results message for
170
+ // R_Y. repairToolPairing consumes both pending ids at the first one, stubs
171
+ // Y there and drops the real R_Y as unmatched, destroying the parallel
172
+ // result this merge exists to preserve. unit-import.mjs pins the shape.
173
+ if (!content.length) { dropped.abortedTurns++; continue; }
174
+ // Blocks were present but every one was filtered — content really was
175
+ // dropped here, so keep the slot and say so. Empty content is rejected by
176
+ // the API, and dropping the message would break tool pairing.
177
+ if (!blocks.length) blocks.push({ type: "text", text: "[incompatible content omitted]" });
178
+ turnResults = null;
179
+ turnAssistantIdx = anthropicMessages.length;
180
+ anthropicMessages.push({ role: "assistant", content: blocks });
181
+ } else if (msg.role === "toolResult") {
182
+ // Pi records one message per tool result, and repairToolPairing only
183
+ // pairs results that share the user message directly after their
184
+ // assistant message. Split across messages, the second and later results
185
+ // match no pending tool_use id: they are dropped and replaced with a
186
+ // synthetic "[no tool result recorded]", so every rebuild silently
187
+ // destroyed the output of parallel tool calls. Session.importMessages
188
+ // applies the repair itself, so this cannot be opted out of by skipping
189
+ // our own call. (Claude Code's live writer splits a turn across records
190
+ // — one per content block, one per result — so the single-message shape
191
+ // is repairToolPairing's requirement, not a copy of CC's own layout;
192
+ // tests/int-cc-contracts.mjs pins both facts.)
193
+ //
194
+ // Collecting into the turn's first result message rather than the
195
+ // immediately preceding one also handles a steer landing mid-execution,
196
+ // which pi records between the results (see extractAllToolResults).
197
+ // The results also have to sit *directly* after their assistant message:
198
+ // repairToolPairing consumes the turn's pending ids at the first user
199
+ // message that follows it, so a steer arriving before the first result —
200
+ // what any steer during a slow first tool looks like — would otherwise
201
+ // take the stubs and strand every real result behind it.
202
+ //
203
+ // Both hoists reorder the steer against wall-clock: Claude sees results
204
+ // that were still running when the steer arrived. Claude Code normalizes
205
+ // to the same order — it records a mid-turn steer as an `attachment`, and
206
+ // reorderAttachmentsForAPI (claude-code-rip src/utils/messages.ts:1481)
207
+ // bubbles attachments up to the nearest assistant or tool_result message
208
+ // and re-inserts them after it. The on-disk form differs, the order does not.
209
+ const block = { type: "tool_result", tool_use_id: sanitizeToolId(msg.toolCallId, sanitizedIds), content: toolResultContent(msg.content), is_error: msg.isError };
210
+ if (turnResults) {
211
+ turnResults.content.push(block);
212
+ } else {
213
+ turnResults = { role: "user", content: [block] };
214
+ // A result with no assistant message before it is malformed history;
215
+ // appending keeps it in order for repairToolPairing to discard.
216
+ anthropicMessages.splice(turnAssistantIdx === null ? anthropicMessages.length : turnAssistantIdx + 1, 0, turnResults);
217
+ }
218
+ }
219
+ }
220
+
221
+ return { anthropicMessages, sanitizedIds, dropped };
222
+ }
@@ -0,0 +1,47 @@
1
+ // Tool-result extraction: walks the context tail to collect this turn's
2
+ // tool results. Pi appends results to context and calls the provider again;
3
+ // this scrapes them back out. Walks past user messages (steer/followUp) that
4
+ // pi may inject between toolResults. Stops at the nearest assistant message
5
+ // (turn boundary).
6
+ // Extracted from index.ts so tests can import without activating the extension.
7
+
8
+ export type McpContent = Array<
9
+ | { type: "text"; text: string }
10
+ | { type: "image"; data: string; mimeType: string }
11
+ >;
12
+
13
+ export interface McpResult {
14
+ content: McpContent;
15
+ isError?: boolean;
16
+ toolCallId?: string;
17
+ [key: string]: unknown;
18
+ }
19
+
20
+ export function toolResultToMcpContent(
21
+ content: string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>,
22
+ ): McpContent {
23
+ if (typeof content === "string") return [{ type: "text", text: content || "" }];
24
+ if (!Array.isArray(content)) return [{ type: "text", text: "" }];
25
+ const blocks: McpContent = [];
26
+ for (const block of content) {
27
+ if (block.type === "text" && block.text) blocks.push({ type: "text", text: block.text });
28
+ else if (block.type === "image" && block.data && block.mimeType) blocks.push({ type: "image", data: block.data, mimeType: block.mimeType });
29
+ }
30
+ return blocks.length ? blocks : [{ type: "text", text: "" }];
31
+ }
32
+
33
+ // Returns { results, stopIdx } so callers can log the walk boundary.
34
+ export function extractAllToolResults(
35
+ messages: Array<{ role: string; content?: unknown; toolCallId?: string; isError?: boolean; [key: string]: unknown }>,
36
+ ): { results: McpResult[]; stopIdx: number } {
37
+ const results: McpResult[] = [];
38
+ let stopIdx = -1;
39
+ for (let i = messages.length - 1; i >= 0; i--) {
40
+ const msg = messages[i];
41
+ if (msg.role === "toolResult") {
42
+ results.unshift({ content: toolResultToMcpContent(msg.content as string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>), isError: msg.isError, toolCallId: msg.toolCallId });
43
+ } else if (msg.role === "assistant") { stopIdx = i; break; }
44
+ // user messages: skip (steer/followUp injected mid-tool-execution)
45
+ }
46
+ return { results, stopIdx };
47
+ }