@vanillagreen/pi-claude-bridge 1.0.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/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +53 -0
- package/assets/claude-bridge1.png +0 -0
- package/assets/claude-bridge2.png +0 -0
- package/bundle/index.js +34422 -0
- package/package.json +151 -0
- package/src/agents-md.ts +54 -0
- package/src/config.ts +144 -0
- package/src/convert.ts +100 -0
- package/src/extract-tool-results.ts +47 -0
- package/src/index.ts +1225 -0
- package/src/models.ts +25 -0
- package/src/prompt-context.ts +138 -0
- package/src/query-state.ts +80 -0
- package/src/session-verify.ts +38 -0
- package/src/skills.ts +24 -0
- package/src/typebox-to-zod.ts +42 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,1225 @@
|
|
|
1
|
+
import { calculateCost, getModels, type AssistantMessage, type AssistantMessageEventStream, type Context, type Model, type SimpleStreamOptions, type Tool } from "@mariozechner/pi-ai";
|
|
2
|
+
import * as piAi from "@mariozechner/pi-ai";
|
|
3
|
+
import { type ExtensionAPI, type ExtensionUIContext } from "@mariozechner/pi-coding-agent";
|
|
4
|
+
import { createSdkMcpServer, query, type EffortLevel, type SDKMessage, type SDKUserMessage, type SettingSource } from "@anthropic-ai/claude-agent-sdk";
|
|
5
|
+
import type { Base64ImageSource, ContentBlockParam, MessageParam } from "@anthropic-ai/sdk/resources";
|
|
6
|
+
import { createSession, deleteSession, repairToolPairing } from "cc-session-io";
|
|
7
|
+
import { accessSync, appendFileSync, constants as fsConstants, mkdirSync, realpathSync, statSync } from "fs";
|
|
8
|
+
import { homedir } from "os";
|
|
9
|
+
import { delimiter, dirname, join } from "path";
|
|
10
|
+
import { PROVIDER_ID, messageContentToText, convertPiMessages } from "./convert.js";
|
|
11
|
+
import { buildModels } from "./models.js";
|
|
12
|
+
import { MCP_SERVER_NAME, MCP_TOOL_PREFIX, extractSkillsBlock } from "./skills.js";
|
|
13
|
+
import { verifyWrittenSession as _verifyWrittenSession } from "./session-verify.js";
|
|
14
|
+
import { extractAllToolResults as _extractAllToolResults, type McpResult } from "./extract-tool-results.js";
|
|
15
|
+
import { QueryContext, ctx, stackDepth, pushContext, popContext } from "./query-state.js";
|
|
16
|
+
import { loadConfig } from "./config.js";
|
|
17
|
+
import { extractAgentsAppend } from "./agents-md.js";
|
|
18
|
+
import { buildPromptContextAppend } from "./prompt-context.js";
|
|
19
|
+
import { jsonSchemaToZodShape } from "./typebox-to-zod.js";
|
|
20
|
+
|
|
21
|
+
// Compat (#2): use factory if available (pi-ai ≥0.66), else fall back to constructor (gsd-pi etc.)
|
|
22
|
+
const _piAi = piAi as any;
|
|
23
|
+
const newAssistantMessageEventStream: () => AssistantMessageEventStream =
|
|
24
|
+
typeof _piAi.createAssistantMessageEventStream === "function"
|
|
25
|
+
? _piAi.createAssistantMessageEventStream
|
|
26
|
+
: () => new _piAi.AssistantMessageEventStream();
|
|
27
|
+
|
|
28
|
+
// --- Debug logging ---
|
|
29
|
+
// CLAUDE_BRIDGE_DEBUG=1 enables debug logging to ~/.pi/agent/claude-bridge.log
|
|
30
|
+
|
|
31
|
+
const DEBUG = process.env.CLAUDE_BRIDGE_DEBUG === "1";
|
|
32
|
+
const DEBUG_LOG_PATH = process.env.CLAUDE_BRIDGE_DEBUG_PATH || join(homedir(), ".pi", "agent", "claude-bridge.log");
|
|
33
|
+
const DIAG_LOG_PATH = join(homedir(), ".pi", "agent", "claude-bridge-diag.log");
|
|
34
|
+
|
|
35
|
+
// Ensure log directories exist when debug is enabled
|
|
36
|
+
if (DEBUG) {
|
|
37
|
+
try {
|
|
38
|
+
mkdirSync(dirname(DEBUG_LOG_PATH), { recursive: true });
|
|
39
|
+
mkdirSync(dirname(DIAG_LOG_PATH), { recursive: true });
|
|
40
|
+
} catch {
|
|
41
|
+
// If directory creation fails, debug functions will throw on first use
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Unique per module evaluation — confirms whether subagents share module state
|
|
46
|
+
const moduleInstanceId = Math.random().toString(36).slice(2, 8);
|
|
47
|
+
|
|
48
|
+
function debug(...args: unknown[]) {
|
|
49
|
+
if (!DEBUG) return;
|
|
50
|
+
const ts = new Date().toISOString();
|
|
51
|
+
const fmt = (a: unknown): string => {
|
|
52
|
+
if (typeof a === "string") return a;
|
|
53
|
+
if (a instanceof Error) return `${a.name}: ${a.message}${a.stack ? "\n" + a.stack : ""}`;
|
|
54
|
+
return JSON.stringify(a);
|
|
55
|
+
};
|
|
56
|
+
const msg = args.map(fmt).join(" ");
|
|
57
|
+
appendFileSync(DEBUG_LOG_PATH, `[${ts}] [${moduleInstanceId}] ${msg}\n`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function executableFromPath(name: string): string | undefined {
|
|
61
|
+
const paths = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
|
|
62
|
+
for (const dir of paths) {
|
|
63
|
+
const candidate = join(dir, name);
|
|
64
|
+
try {
|
|
65
|
+
accessSync(candidate, fsConstants.X_OK);
|
|
66
|
+
return candidate;
|
|
67
|
+
} catch {
|
|
68
|
+
// keep searching
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resolveClaudeExecutable(configured?: string): string | undefined {
|
|
75
|
+
const trimmed = configured?.trim();
|
|
76
|
+
if (trimmed) return trimmed;
|
|
77
|
+
return executableFromPath("claude") ?? executableFromPath("claude-code");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Per-query CLI debug capture. When CLAUDE_BRIDGE_DEBUG=1, ask the Claude Code
|
|
81
|
+
// CLI subprocess to write its own debug log to a file we choose, and also
|
|
82
|
+
// forward its stderr into our debug stream. Drops straight into the real SDK's
|
|
83
|
+
// Options — see @anthropic-ai/claude-agent-sdk sdk.d.ts:1245 (debug, debugFile,
|
|
84
|
+
// stderr). Without this, CC's internal view of the world is invisible to us
|
|
85
|
+
// and "No conversation found" / empty-error reports are unactionable.
|
|
86
|
+
let nextCliDebugSeq = 1;
|
|
87
|
+
function makeCliDebugOptions(tag: string): { debug?: boolean; debugFile?: string; stderr?: (data: string) => void } {
|
|
88
|
+
if (!DEBUG) return {};
|
|
89
|
+
const seq = nextCliDebugSeq++;
|
|
90
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
91
|
+
const logDir = join(dirname(DEBUG_LOG_PATH), "cc-cli-logs");
|
|
92
|
+
try { mkdirSync(logDir, { recursive: true }); } catch { /* ignore */ }
|
|
93
|
+
const debugFile = join(logDir, `${ts}-${tag}-${seq}.log`);
|
|
94
|
+
debug(`cli-debug: ${tag} #${seq} → ${debugFile}`);
|
|
95
|
+
return {
|
|
96
|
+
debug: true,
|
|
97
|
+
debugFile,
|
|
98
|
+
stderr: (data: string) => {
|
|
99
|
+
for (const line of data.split(/\r?\n/)) {
|
|
100
|
+
if (line) debug(`[cli-stderr ${tag}#${seq}] ${line}`);
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Unconditional diagnostic dump — for "should never happen" paths */
|
|
107
|
+
function diagDump(label: string, data: Record<string, unknown>) {
|
|
108
|
+
const ts = new Date().toISOString();
|
|
109
|
+
const entry = { ts, moduleInstanceId, label, ...data };
|
|
110
|
+
appendFileSync(DIAG_LOG_PATH, JSON.stringify(entry) + "\n");
|
|
111
|
+
debug(`DIAG: ${label} (see ${DIAG_LOG_PATH})`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- Constants ---
|
|
115
|
+
|
|
116
|
+
// Global key to prevent re-registration of the provider across module reloads.
|
|
117
|
+
//
|
|
118
|
+
// Extensions like pi-subagents spawn a subagent and it loads this module
|
|
119
|
+
// again. Without this guard, the subagent's call to registerProvider() would
|
|
120
|
+
// overwrite the parent's `streamSimple` function reference in the shared
|
|
121
|
+
// ModelRegistry. When the parent later delivers a tool result, it would call
|
|
122
|
+
// the subagent's `streamSimple` (which has empty state) instead of its own.
|
|
123
|
+
//
|
|
124
|
+
// By storing the active streamSimple in a Symbol.for() global (shared across all
|
|
125
|
+
// module instances), we ensure only the FIRST instance to register takes effect.
|
|
126
|
+
// Subsequent instances wrap the stored function instead of overwriting it.
|
|
127
|
+
//
|
|
128
|
+
// On session_shutdown (including /reload), clearSession() resets this so a fresh
|
|
129
|
+
// registration can occur for the next session.
|
|
130
|
+
const ACTIVE_STREAM_SIMPLE_KEY = Symbol.for("claude-bridge:activeStreamSimple");
|
|
131
|
+
|
|
132
|
+
const SDK_TO_PI_TOOL_NAME: Record<string, string> = {
|
|
133
|
+
read: "read", write: "write", edit: "edit", bash: "bash",
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// MODELS is buildModels(getModels("anthropic")) — projection kept in models.js.
|
|
137
|
+
const MODELS = buildModels(getModels("anthropic"));
|
|
138
|
+
|
|
139
|
+
// Disable Claude Code built-ins in the provider path. Pi owns tool execution;
|
|
140
|
+
// Claude reaches Pi tools through the bridged MCP server instead.
|
|
141
|
+
const DISALLOWED_BUILTIN_TOOLS = [
|
|
142
|
+
"Read", "Write", "Edit", "Glob", "Grep", "Bash", "Agent",
|
|
143
|
+
"NotebookEdit", "EnterWorktree", "ExitWorktree",
|
|
144
|
+
"CronCreate", "CronDelete", "TeamCreate", "TeamDelete",
|
|
145
|
+
"WebFetch", "WebSearch",
|
|
146
|
+
"AskUserQuestion", "EnterPlanMode", "ExitPlanMode",
|
|
147
|
+
"ToolSearch", "ScheduleWakeup",
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
// --- Session persistence ---
|
|
151
|
+
|
|
152
|
+
interface SessionState {
|
|
153
|
+
sessionId: string;
|
|
154
|
+
cursor: number;
|
|
155
|
+
cwd: string;
|
|
156
|
+
// Force the next syncSharedSession call down the REBUILD path. Set when
|
|
157
|
+
// pi has mutated its messages array out from under us (compact, tree
|
|
158
|
+
// navigation) or after an abort left the JSONL in an indeterminate state.
|
|
159
|
+
// REBUILD wipes and rewrites the file to match pi's current history.
|
|
160
|
+
needsRebuild?: boolean;
|
|
161
|
+
// Set ONLY after an abort. The killed CC subprocess may still be flushing
|
|
162
|
+
// a late "[Request interrupted by user]" record to the session JSONL.
|
|
163
|
+
// Reusing the same sessionId/path would race that orphan write into our
|
|
164
|
+
// fresh file and break CC's parent-uuid chain on the next resume. When
|
|
165
|
+
// this flag is set, REBUILD takes a fresh UUID and skips deleteSession
|
|
166
|
+
// so the orphan writes land on a dead inode. Compact/tree do NOT set
|
|
167
|
+
// this — there's no concurrent CC writer during those events, so
|
|
168
|
+
// in-place rebuild (preserve UUID, deleteSession + createSession) is safe.
|
|
169
|
+
forceRotate?: boolean;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let sharedSession: SessionState | null = null;
|
|
173
|
+
|
|
174
|
+
// Convert pi messages to Anthropic API format for session import.
|
|
175
|
+
// Lossy: non-Anthropic thinking blocks are dropped (no valid signature), and only
|
|
176
|
+
// text/image/toolCall block types are handled. If all blocks in an assistant message
|
|
177
|
+
// are filtered, the message is dropped — which can create invalid sequences (e.g.
|
|
178
|
+
// two user messages in a row, or tool_result without preceding tool_use).
|
|
179
|
+
function convertAndImportMessages(
|
|
180
|
+
session: ReturnType<typeof createSession>,
|
|
181
|
+
messages: Context["messages"],
|
|
182
|
+
customToolNameToSdk?: Map<string, string>,
|
|
183
|
+
): void {
|
|
184
|
+
const { anthropicMessages, sanitizedIds } = convertPiMessages(messages, customToolNameToSdk);
|
|
185
|
+
|
|
186
|
+
debug(`convertAndImportMessages: ${messages.length} pi msgs → ${anthropicMessages.length} anthropic msgs`);
|
|
187
|
+
debug(`convertAndImportMessages: imported roles:`, anthropicMessages.map((m, i) => {
|
|
188
|
+
const c = m.content;
|
|
189
|
+
if (typeof c === "string") return `[${i}]${m.role}:text`;
|
|
190
|
+
if (Array.isArray(c)) return `[${i}]${m.role}:${(c).map((b) => b.type).join("+")}`;
|
|
191
|
+
return `[${i}]${m.role}:?`;
|
|
192
|
+
}).join(" "));
|
|
193
|
+
if (sanitizedIds.size > 0) {
|
|
194
|
+
debug(`convertAndImportMessages: sanitized ${sanitizedIds.size} tool IDs:`,
|
|
195
|
+
[...sanitizedIds.entries()].map(([orig, clean]) => orig === clean ? orig : `${orig}→${clean}`).join(", "));
|
|
196
|
+
}
|
|
197
|
+
// Pre-repair for debug logging; importMessages also repairs internally (idempotent).
|
|
198
|
+
const repaired = repairToolPairing(anthropicMessages);
|
|
199
|
+
if (repaired.length !== anthropicMessages.length) {
|
|
200
|
+
debug(`convertAndImportMessages: repairToolPairing ${anthropicMessages.length} → ${repaired.length} msgs`);
|
|
201
|
+
}
|
|
202
|
+
if (repaired.length) session.importMessages(repaired);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Pi doesn't pass tool results directly — it appends them to the context and calls
|
|
206
|
+
// the provider again. Thin wrapper over extract-tool-results.js that adds per-turn
|
|
207
|
+
// debug logging at the extraction boundary.
|
|
208
|
+
function extractAllToolResults(context: Context): McpResult[] {
|
|
209
|
+
const { results, stopIdx } = _extractAllToolResults(context.messages as unknown as Array<{ role: string; [key: string]: unknown }>);
|
|
210
|
+
debug(`extractAllToolResults: ${results.length} results from ${context.messages.length} msgs, stopped at index ${stopIdx}`);
|
|
211
|
+
debug(`extractAllToolResults: all msg roles:`, context.messages.map((m, i) => `[${i}]${m.role}`).join(" "));
|
|
212
|
+
for (let r = 0; r < results.length; r++) {
|
|
213
|
+
debug(`extractAllToolResults: result[${r}] id=${results[r].toolCallId}${results[r].isError ? " ERROR" : ""} preview:`, JSON.stringify(results[r].content).slice(0, 150));
|
|
214
|
+
}
|
|
215
|
+
return results;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Extract the last user message from context as a prompt string. Returns null if last message is not a user message. */
|
|
219
|
+
function extractUserPrompt(messages: Context["messages"]): string | null {
|
|
220
|
+
const last = messages[messages.length - 1];
|
|
221
|
+
if (!last || last.role !== "user") return null;
|
|
222
|
+
if (typeof last.content === "string") return last.content;
|
|
223
|
+
return messageContentToText(last.content) || "";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Extract the last user message as ContentBlockParam[] (preserving images).
|
|
227
|
+
* Returns null if no images — caller should fall back to string prompt. */
|
|
228
|
+
function extractUserPromptBlocks(messages: Context["messages"]): ContentBlockParam[] | null {
|
|
229
|
+
const last = messages[messages.length - 1];
|
|
230
|
+
if (!last || last.role !== "user") return null;
|
|
231
|
+
if (typeof last.content === "string") {
|
|
232
|
+
debug(`extractUserPromptBlocks: content is string (length=${last.content.length})`);
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
if (!Array.isArray(last.content)) {
|
|
236
|
+
debug(`extractUserPromptBlocks: content is ${typeof last.content}`);
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
debug(`extractUserPromptBlocks: ${last.content.length} blocks, types=${last.content.map((b: any) => b.type).join(",")}`);
|
|
240
|
+
let hasImage = false;
|
|
241
|
+
const blocks: ContentBlockParam[] = [];
|
|
242
|
+
for (const block of last.content) {
|
|
243
|
+
if (block.type === "text" && block.text) {
|
|
244
|
+
blocks.push({ type: "text", text: block.text });
|
|
245
|
+
} else if (block.type === "image") {
|
|
246
|
+
debug(`image block: mimeType=${(block as any).mimeType}, data length=${((block as any).data ?? "").length}, keys=${Object.keys(block).join(",")}`);
|
|
247
|
+
if (!(block as any).data || !(block as any).mimeType) {
|
|
248
|
+
debug(`image block missing data or mimeType, skipping`);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
hasImage = true;
|
|
252
|
+
blocks.push({
|
|
253
|
+
type: "image",
|
|
254
|
+
source: { type: "base64", media_type: block.mimeType as Base64ImageSource["media_type"], data: block.data },
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return hasImage ? blocks : null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function* wrapPromptStream(blocks: ContentBlockParam[]): AsyncIterable<SDKUserMessage> {
|
|
262
|
+
yield {
|
|
263
|
+
type: "user",
|
|
264
|
+
message: { role: "user", content: blocks } as MessageParam,
|
|
265
|
+
parent_tool_use_id: null,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
interface SyncResult {
|
|
271
|
+
sessionId: string | null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Ensure the shared session has all messages up to (but not including) the last user message.
|
|
276
|
+
* Returns session ID to resume from, or null if no resume needed.
|
|
277
|
+
*/
|
|
278
|
+
// Read the session file we just wrote and sanity-check it. Warns instead of
|
|
279
|
+
// throwing — CC may be more tolerant than our checks, so a false positive
|
|
280
|
+
// shouldn't block the user. Pure logic is in session-verify.js; this wrapper
|
|
281
|
+
// fans each warning out to debug log + piUI notify + diagDump.
|
|
282
|
+
function verifyWrittenSession(
|
|
283
|
+
jsonlPath: string,
|
|
284
|
+
expectedSessionId: string,
|
|
285
|
+
expectedRecordCount: number,
|
|
286
|
+
cwd: string,
|
|
287
|
+
): void {
|
|
288
|
+
const warnings = _verifyWrittenSession(jsonlPath, expectedSessionId, expectedRecordCount);
|
|
289
|
+
for (const msg of warnings) {
|
|
290
|
+
debug(`WARNING session verify: ${msg}`);
|
|
291
|
+
piUI?.notify(
|
|
292
|
+
`Session file issue: ${msg}\n` +
|
|
293
|
+
`cwd=${cwd} realpath=${safeRealpath(cwd)} CLAUDE_CONFIG_DIR=${process.env.CLAUDE_CONFIG_DIR ?? "(unset)"}\n` +
|
|
294
|
+
`Please copy and paste this message into a new issue at https://github.com/elidickinson/pi-claude-bridge/issues/new` +
|
|
295
|
+
(DEBUG ? ` and attach ${DEBUG_LOG_PATH}` : ` (rerun with CLAUDE_BRIDGE_DEBUG=1 to capture a debug log)`),
|
|
296
|
+
"warning",
|
|
297
|
+
);
|
|
298
|
+
diagDump("session_verify_fail", { msg, jsonlPath, cwd, realpath: safeRealpath(cwd), claudeConfigDir: process.env.CLAUDE_CONFIG_DIR ?? null });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function safeRealpath(p: string): string {
|
|
303
|
+
try { return realpathSync(p); } catch (e) { return `<failed: ${(e as Error).message}>`; }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Diagnostic snapshot of where a session file was just written. Catches the
|
|
307
|
+
// class of bugs where pi writes to ~/.claude/projects/<X> but CC SDK reads
|
|
308
|
+
// from ~/.claude/projects/<Y> (symlinks, CLAUDE_CONFIG_DIR, hash mismatch).
|
|
309
|
+
function debugSessionPaths(label: string, cwd: string, jsonlPath: string): void {
|
|
310
|
+
const realCwd = safeRealpath(cwd);
|
|
311
|
+
let fileSize: number | null = null;
|
|
312
|
+
let fileExists = false;
|
|
313
|
+
try {
|
|
314
|
+
const st = statSync(jsonlPath);
|
|
315
|
+
fileExists = true;
|
|
316
|
+
fileSize = st.size;
|
|
317
|
+
} catch { /* file may not exist yet */ }
|
|
318
|
+
debug(`${label}: cwd=${cwd}`);
|
|
319
|
+
if (realCwd !== cwd) debug(`${label}: realpath(cwd)=${realCwd} (DIFFERS — symlink-resolved path is what CC SDK uses)`);
|
|
320
|
+
debug(`${label}: jsonlPath=${jsonlPath}`);
|
|
321
|
+
debug(`${label}: fileExists=${fileExists}${fileSize != null ? ` size=${fileSize}` : ""}`);
|
|
322
|
+
debug(`${label}: env.CLAUDE_CONFIG_DIR=${process.env.CLAUDE_CONFIG_DIR ?? "(unset)"} HOME=${process.env.HOME ?? "(unset)"}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Two semantic paths:
|
|
326
|
+
// REUSE — pi's history is in sync with the existing sharedSession (or drifted
|
|
327
|
+
// only by the trailing final-assistant message that pi appends after
|
|
328
|
+
// streamSimple returns, which CC's own persisted session already has).
|
|
329
|
+
// Returns the existing sessionId. Keeps CC's prompt cache warm.
|
|
330
|
+
// REBUILD — no session yet, or pi's history has diverged (non-trailing
|
|
331
|
+
// missed messages, e.g. another provider took a turn). Wipes the existing
|
|
332
|
+
// session file (if any) and writes a fresh one containing all prior
|
|
333
|
+
// messages, reusing the same sessionId across rebuilds so UUIDs stay
|
|
334
|
+
// stable for the lifetime of pi's session.
|
|
335
|
+
//
|
|
336
|
+
// Why a full rebuild rather than patching:
|
|
337
|
+
// Injecting deltas into an existing session creates a branch that CC's
|
|
338
|
+
// --resume doesn't follow (documented attempt prior to this). A complete
|
|
339
|
+
// overwrite at the same path is simpler and correct.
|
|
340
|
+
//
|
|
341
|
+
// Why reuse the sessionId across rebuilds:
|
|
342
|
+
// CC re-reads the JSONL on every --resume call — no in-process UUID
|
|
343
|
+
// caching. Validated in tests/exp-session-clear.mjs, including the case
|
|
344
|
+
// where CC had appended its own tool_use/tool_result records between
|
|
345
|
+
// rebuilds. Preserving the UUID means stable log correlation across
|
|
346
|
+
// provider switches and no orphaned session files.
|
|
347
|
+
//
|
|
348
|
+
// Log strings still say "Case 1/2/3/4" so existing diagnostics (int-cache.sh,
|
|
349
|
+
// int-session-resume.mjs) keep grepping the same anchors.
|
|
350
|
+
function syncSharedSession(
|
|
351
|
+
messages: Context["messages"],
|
|
352
|
+
cwd: string,
|
|
353
|
+
customToolNameToSdk?: Map<string, string>,
|
|
354
|
+
modelId?: string,
|
|
355
|
+
): SyncResult {
|
|
356
|
+
const priorMessages = messages.slice(0, -1); // everything before the new user prompt
|
|
357
|
+
|
|
358
|
+
// REUSE path
|
|
359
|
+
if (sharedSession && !sharedSession.needsRebuild) {
|
|
360
|
+
const missed = priorMessages.slice(sharedSession.cursor);
|
|
361
|
+
const trailingAssistantOnly =
|
|
362
|
+
missed.length === 1 && (missed[0] as { role?: string }).role === "assistant";
|
|
363
|
+
if (missed.length === 0 || trailingAssistantOnly) {
|
|
364
|
+
if (trailingAssistantOnly) {
|
|
365
|
+
sharedSession = { ...sharedSession, cursor: priorMessages.length, cwd };
|
|
366
|
+
}
|
|
367
|
+
debug(`Case 3: ${trailingAssistantOnly ? "advanced cursor past trailing assistant, " : ""}resuming session ${sharedSession.sessionId.slice(0, 8)}, cursor=${sharedSession.cursor}`);
|
|
368
|
+
debug(`syncResult: path=reuse sessionId=${sharedSession.sessionId} cursor=${sharedSession.cursor}`);
|
|
369
|
+
return { sessionId: sharedSession.sessionId };
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// REBUILD path
|
|
374
|
+
if (priorMessages.length === 0) {
|
|
375
|
+
debug(`Case 1: clean start, ${messages.length} total messages`);
|
|
376
|
+
debug(`syncResult: path=clean-start`);
|
|
377
|
+
return { sessionId: null };
|
|
378
|
+
}
|
|
379
|
+
const previousSessionId = sharedSession?.sessionId;
|
|
380
|
+
const previousCursor = sharedSession?.cursor ?? 0;
|
|
381
|
+
// preserveId: rebuild in place (deleteSession + createSession with the
|
|
382
|
+
// existing UUID), so prompt-cache UUIDs stay stable for log correlation
|
|
383
|
+
// and for any tools that key off them. Skipped only when there's a
|
|
384
|
+
// concurrent writer we shouldn't race — see forceRotate docs above.
|
|
385
|
+
const preserveId = previousSessionId !== undefined && !sharedSession?.forceRotate;
|
|
386
|
+
if (preserveId) {
|
|
387
|
+
// Wipe prior jsonl + companion dir (no-op if nothing to wipe).
|
|
388
|
+
deleteSession(previousSessionId!, cwd, process.env.CLAUDE_CONFIG_DIR);
|
|
389
|
+
}
|
|
390
|
+
const session = createSession({
|
|
391
|
+
projectPath: cwd,
|
|
392
|
+
claudeDir: process.env.CLAUDE_CONFIG_DIR,
|
|
393
|
+
...(preserveId ? { sessionId: previousSessionId } : {}),
|
|
394
|
+
...(modelId ? { model: modelId } : {}),
|
|
395
|
+
});
|
|
396
|
+
convertAndImportMessages(session, priorMessages, customToolNameToSdk);
|
|
397
|
+
session.save();
|
|
398
|
+
verifyWrittenSession(session.jsonlPath, session.sessionId, session.messages.length, cwd);
|
|
399
|
+
sharedSession = { sessionId: session.sessionId, cursor: priorMessages.length, cwd };
|
|
400
|
+
if (previousSessionId === undefined) {
|
|
401
|
+
debug(`Case 2: first turn with ${priorMessages.length} prior messages → session ${session.sessionId.slice(0, 8)}, ${session.messages.length} records`);
|
|
402
|
+
} else if (preserveId) {
|
|
403
|
+
const missedCount = priorMessages.length - previousCursor;
|
|
404
|
+
debug(`Case 4: ${missedCount} missed messages, ${priorMessages.length} total → rewrote session ${session.sessionId.slice(0, 8)} (same id), ${session.messages.length} records`);
|
|
405
|
+
} else {
|
|
406
|
+
debug(`Case 4 post-abort: ${priorMessages.length} total → new session ${session.sessionId.slice(0, 8)} (was ${previousSessionId.slice(0, 8)}, rotated to avoid race with orphan writer), ${session.messages.length} records`);
|
|
407
|
+
}
|
|
408
|
+
debugSessionPaths(`${session.sessionId.slice(0, 8)}`, cwd, session.jsonlPath);
|
|
409
|
+
debug(`syncResult: path=rebuild sessionId=${session.sessionId} priors=${priorMessages.length} ${previousSessionId === undefined ? "first" : preserveId ? "preserved" : "rotated-post-abort"}`);
|
|
410
|
+
return { sessionId: session.sessionId };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// --- Provider helpers: tool name mapping ---
|
|
414
|
+
|
|
415
|
+
function mapToolName(name: string, customToolNameToPi?: Map<string, string>): string {
|
|
416
|
+
const normalized = name.toLowerCase();
|
|
417
|
+
const builtin = SDK_TO_PI_TOOL_NAME[normalized];
|
|
418
|
+
if (builtin) return builtin;
|
|
419
|
+
if (customToolNameToPi) {
|
|
420
|
+
const mapped = customToolNameToPi.get(name) ?? customToolNameToPi.get(normalized);
|
|
421
|
+
if (mapped) return mapped;
|
|
422
|
+
}
|
|
423
|
+
if (normalized.startsWith(MCP_TOOL_PREFIX)) return name.slice(MCP_TOOL_PREFIX.length);
|
|
424
|
+
return name;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Renames for Claude Code SDK param names that differ from pi's native names.
|
|
428
|
+
// Keys not listed here pass through unchanged, so new pi params work automatically.
|
|
429
|
+
const SDK_KEY_RENAMES: Record<string, Record<string, string>> = {
|
|
430
|
+
read: { file_path: "path" },
|
|
431
|
+
write: { file_path: "path" },
|
|
432
|
+
edit: { file_path: "path", old_string: "oldText", new_string: "newText", old_text: "oldText", new_text: "newText" },
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
// Maps SDK tool args to pi tool args via key renaming + pass-through.
|
|
436
|
+
// Pi's own prepareArguments hooks handle any structural transforms (e.g. edit oldText/newText → edits[]).
|
|
437
|
+
function mapToolArgs(
|
|
438
|
+
toolName: string, args: Record<string, unknown> | undefined,
|
|
439
|
+
): Record<string, unknown> {
|
|
440
|
+
const input = args ?? {};
|
|
441
|
+
const renames = SDK_KEY_RENAMES[toolName.toLowerCase()];
|
|
442
|
+
const result: Record<string, unknown> = {};
|
|
443
|
+
for (const [key, value] of Object.entries(input)) {
|
|
444
|
+
const piKey = renames?.[key] ?? key;
|
|
445
|
+
if (!(piKey in result)) result[piKey] = value; // first alias wins
|
|
446
|
+
}
|
|
447
|
+
// Pi bash has no default timeout; add a safety default
|
|
448
|
+
if (toolName.toLowerCase() === "bash" && result.timeout == null) {
|
|
449
|
+
result.timeout = 120;
|
|
450
|
+
}
|
|
451
|
+
return result;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// --- Provider helpers: tool resolution ---
|
|
455
|
+
|
|
456
|
+
// --- Provider helpers: tool bridge ---
|
|
457
|
+
|
|
458
|
+
// --- Query state ---
|
|
459
|
+
// QueryContext + context stack live in query-state.js so tests can import
|
|
460
|
+
// them without activating the extension. `ctx()`, `pushContext()`, `popContext()`
|
|
461
|
+
// are imported at the top of this file.
|
|
462
|
+
|
|
463
|
+
// Global (not query state):
|
|
464
|
+
let piUI: ExtensionUIContext | null = null;
|
|
465
|
+
|
|
466
|
+
function resolveMcpTools(context: Context, excludeToolName?: string): {
|
|
467
|
+
mcpTools: Tool[];
|
|
468
|
+
customToolNameToSdk: Map<string, string>;
|
|
469
|
+
customToolNameToPi: Map<string, string>;
|
|
470
|
+
} {
|
|
471
|
+
const mcpTools: Tool[] = [];
|
|
472
|
+
const customToolNameToSdk = new Map<string, string>();
|
|
473
|
+
const customToolNameToPi = new Map<string, string>();
|
|
474
|
+
|
|
475
|
+
if (!context.tools) return { mcpTools, customToolNameToSdk, customToolNameToPi };
|
|
476
|
+
|
|
477
|
+
for (const tool of context.tools) {
|
|
478
|
+
if (tool.name === excludeToolName) continue;
|
|
479
|
+
const sdkName = `${MCP_TOOL_PREFIX}${tool.name}`;
|
|
480
|
+
mcpTools.push(tool);
|
|
481
|
+
customToolNameToSdk.set(tool.name, sdkName);
|
|
482
|
+
customToolNameToSdk.set(tool.name.toLowerCase(), sdkName);
|
|
483
|
+
customToolNameToPi.set(sdkName, tool.name);
|
|
484
|
+
customToolNameToPi.set(sdkName.toLowerCase(), tool.name);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return { mcpTools, customToolNameToSdk, customToolNameToPi };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Creates an MCP server that bridges pi tools to the SDK. Each tool handler
|
|
491
|
+
// blocks on a Promise until pi delivers the tool result via streamSimple.
|
|
492
|
+
// Handlers are assigned toolCallIds from turnToolCallIds (populated when the SDK
|
|
493
|
+
// emits tool_use blocks). Results are matched by ID, not position.
|
|
494
|
+
// Handlers close over the captured `queryCtx`, ensuring they operate on the
|
|
495
|
+
// correct query's state even across pushContext/popContext calls.
|
|
496
|
+
function buildMcpServers(tools: Tool[], queryCtx: QueryContext): Record<string, ReturnType<typeof createSdkMcpServer>> | undefined {
|
|
497
|
+
if (!tools.length) return undefined;
|
|
498
|
+
const mcpTools = tools.map((tool) => ({
|
|
499
|
+
name: tool.name,
|
|
500
|
+
description: tool.description,
|
|
501
|
+
inputSchema: jsonSchemaToZodShape(tool.parameters),
|
|
502
|
+
handler: async () => {
|
|
503
|
+
const toolCallId = queryCtx.turnToolCallIds[queryCtx.nextHandlerIdx++];
|
|
504
|
+
if (!toolCallId) debug(`WARNING: mcp handler ${tool.name} has no toolCallId (idx=${queryCtx.nextHandlerIdx - 1}, available=${queryCtx.turnToolCallIds.length})`);
|
|
505
|
+
if (toolCallId && queryCtx.pendingResults.has(toolCallId)) {
|
|
506
|
+
const result = queryCtx.pendingResults.get(toolCallId)!;
|
|
507
|
+
queryCtx.pendingResults.delete(toolCallId);
|
|
508
|
+
debug(`mcp handler: ${tool.name} [${toolCallId}] → resolved from queue (${queryCtx.pendingResults.size} remaining)`);
|
|
509
|
+
return result;
|
|
510
|
+
}
|
|
511
|
+
debug(`mcp handler: ${tool.name} [${toolCallId}] → waiting`);
|
|
512
|
+
return new Promise<McpResult>((resolve) => {
|
|
513
|
+
queryCtx.pendingToolCalls.set(toolCallId, { toolName: tool.name, resolve });
|
|
514
|
+
});
|
|
515
|
+
},
|
|
516
|
+
}));
|
|
517
|
+
const server = createSdkMcpServer({ name: MCP_SERVER_NAME, version: "1.0.0", tools: mcpTools });
|
|
518
|
+
return { [MCP_SERVER_NAME]: server };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// --- Usage helpers ---
|
|
522
|
+
|
|
523
|
+
function updateUsage(output: AssistantMessage, usage: Record<string, number | undefined>, model: Model<any>): void {
|
|
524
|
+
if (usage.input_tokens != null) output.usage.input = usage.input_tokens;
|
|
525
|
+
if (usage.output_tokens != null) output.usage.output = usage.output_tokens;
|
|
526
|
+
if (usage.cache_read_input_tokens != null) output.usage.cacheRead = usage.cache_read_input_tokens;
|
|
527
|
+
if (usage.cache_creation_input_tokens != null) output.usage.cacheWrite = usage.cache_creation_input_tokens;
|
|
528
|
+
output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
|
|
529
|
+
calculateCost(model, output.usage);
|
|
530
|
+
const promptTokens = output.usage.input + output.usage.cacheRead + output.usage.cacheWrite;
|
|
531
|
+
const cachePct = promptTokens > 0 ? Math.round(output.usage.cacheRead / promptTokens * 100) : 0;
|
|
532
|
+
debug(`usage: in=${output.usage.input} out=${output.usage.output} cacheRead=${output.usage.cacheRead} cacheWrite=${output.usage.cacheWrite} total=${output.usage.totalTokens} cachePct=${cachePct}% model=${model.id}`);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// --- Effort level mapping ---
|
|
536
|
+
// Pi reasoning levels → CC SDK effort levels
|
|
537
|
+
|
|
538
|
+
const REASONING_TO_EFFORT: Record<string, EffortLevel> = {
|
|
539
|
+
minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "max",
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
// --- Provider helpers: misc ---
|
|
543
|
+
|
|
544
|
+
function mapStopReason(reason: string | undefined): "stop" | "length" | "toolUse" {
|
|
545
|
+
switch (reason) {
|
|
546
|
+
case "tool_use": return "toolUse";
|
|
547
|
+
case "max_tokens": return "length";
|
|
548
|
+
case "end_turn": default: return "stop";
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function parsePartialJson(input: string, fallback: Record<string, unknown>): Record<string, unknown> {
|
|
553
|
+
if (!input) return fallback;
|
|
554
|
+
try { return JSON.parse(input); } catch { return fallback; }
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
// --- Provider: streaming function ---
|
|
559
|
+
//
|
|
560
|
+
// Push-based streaming with MCP tool bridge:
|
|
561
|
+
// 1. streamSimple starts a query() and kicks off consumeQuery() in background
|
|
562
|
+
// 2. consumeQuery() iterates the SDK generator, pushing events to currentPiStream
|
|
563
|
+
// 3. On tool_use: ends the current pi stream, nulls it out. The MCP handler
|
|
564
|
+
// blocks the generator naturally — no events arrive until resolved.
|
|
565
|
+
// 4. Pi executes the tool, calls streamSimple again. We swap in the new stream,
|
|
566
|
+
// resolve the MCP handler, and the generator unblocks — events flow to new stream.
|
|
567
|
+
//
|
|
568
|
+
// Note: resetTurnState clears turnSawStreamEvent while the generator may still
|
|
569
|
+
// have queued messages from the previous turn. This is safe because step 3 nulls
|
|
570
|
+
// currentPiStream, so any leftover messages hit the `!ctx().currentPiStream` guard
|
|
571
|
+
// in consumeQuery and are skipped before resetTurnState runs.
|
|
572
|
+
|
|
573
|
+
function ensureTurnStarted(): void {
|
|
574
|
+
if (!ctx().turnStarted && ctx().currentPiStream && ctx().turnOutput) {
|
|
575
|
+
ctx().currentPiStream!.push({ type: "start", partial: ctx().turnOutput });
|
|
576
|
+
ctx().turnStarted = true;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function finalizeCurrentStream(stopReason?: string): void {
|
|
581
|
+
if (!ctx().currentPiStream || !ctx().turnOutput) return;
|
|
582
|
+
debug(`provider: finalizeCurrentStream called, stopReason=${stopReason}, turnOutput=${JSON.stringify({stopReason: ctx().turnOutput!.stopReason, error: ctx().turnOutput!.errorMessage})}`);
|
|
583
|
+
if (!ctx().turnStarted) ensureTurnStarted();
|
|
584
|
+
const reason = stopReason === "length" ? "length" : "stop";
|
|
585
|
+
ctx().currentPiStream!.push({ type: "done", reason, message: ctx().turnOutput });
|
|
586
|
+
ctx().currentPiStream!.end();
|
|
587
|
+
ctx().currentPiStream = null;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Maps Anthropic stream events to pi stream events (text, thinking, toolcall).
|
|
591
|
+
* On message_stop with tool_use: ends currentPiStream so pi can execute the tool. */
|
|
592
|
+
function processStreamEvent(
|
|
593
|
+
message: SDKMessage,
|
|
594
|
+
customToolNameToPi: Map<string, string>,
|
|
595
|
+
model: Model<any>,
|
|
596
|
+
): void {
|
|
597
|
+
const c = ctx();
|
|
598
|
+
if (!c.currentPiStream || !c.turnOutput) return;
|
|
599
|
+
c.turnSawStreamEvent = true;
|
|
600
|
+
const event = (message as SDKMessage & { event: any }).event;
|
|
601
|
+
|
|
602
|
+
if (event?.type === "message_start") {
|
|
603
|
+
c.turnToolCallIds = [];
|
|
604
|
+
c.nextHandlerIdx = 0;
|
|
605
|
+
if (event.message?.usage) updateUsage(c.turnOutput, event.message.usage, model);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (event?.type === "content_block_start") {
|
|
610
|
+
ensureTurnStarted();
|
|
611
|
+
if (event.content_block?.type === "text") {
|
|
612
|
+
c.turnBlocks.push({ type: "text", text: "", index: event.index });
|
|
613
|
+
c.currentPiStream!.push({ type: "text_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
|
|
614
|
+
} else if (event.content_block?.type === "thinking") {
|
|
615
|
+
c.turnBlocks.push({ type: "thinking", thinking: "", thinkingSignature: "", index: event.index });
|
|
616
|
+
c.currentPiStream!.push({ type: "thinking_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
|
|
617
|
+
} else if (event.content_block?.type === "tool_use") {
|
|
618
|
+
c.turnSawToolCall = true;
|
|
619
|
+
c.turnToolCallIds.push(event.content_block.id);
|
|
620
|
+
c.turnBlocks.push({
|
|
621
|
+
type: "toolCall", id: event.content_block.id,
|
|
622
|
+
name: mapToolName(event.content_block.name, customToolNameToPi),
|
|
623
|
+
arguments: (event.content_block.input as Record<string, unknown>) ?? {},
|
|
624
|
+
partialJson: "", index: event.index,
|
|
625
|
+
});
|
|
626
|
+
c.currentPiStream!.push({ type: "toolcall_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
|
|
627
|
+
} else {
|
|
628
|
+
debug("processStreamEvent: unhandled content_block_start type", event.content_block?.type);
|
|
629
|
+
}
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (event?.type === "content_block_delta") {
|
|
634
|
+
const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
|
|
635
|
+
const block = c.turnBlocks[index];
|
|
636
|
+
if (!block) return;
|
|
637
|
+
if (event.delta?.type === "text_delta" && block.type === "text") {
|
|
638
|
+
block.text += event.delta.text;
|
|
639
|
+
c.currentPiStream!.push({ type: "text_delta", contentIndex: index, delta: event.delta.text, partial: c.turnOutput });
|
|
640
|
+
} else if (event.delta?.type === "thinking_delta" && block.type === "thinking") {
|
|
641
|
+
block.thinking += event.delta.thinking;
|
|
642
|
+
c.currentPiStream!.push({ type: "thinking_delta", contentIndex: index, delta: event.delta.thinking, partial: c.turnOutput });
|
|
643
|
+
} else if (event.delta?.type === "input_json_delta" && block.type === "toolCall") {
|
|
644
|
+
block.partialJson += event.delta.partial_json;
|
|
645
|
+
block.arguments = parsePartialJson(block.partialJson, block.arguments);
|
|
646
|
+
c.currentPiStream!.push({ type: "toolcall_delta", contentIndex: index, delta: event.delta.partial_json, partial: c.turnOutput });
|
|
647
|
+
} else if (event.delta?.type === "signature_delta" && block.type === "thinking") {
|
|
648
|
+
block.thinkingSignature = (block.thinkingSignature ?? "") + event.delta.signature;
|
|
649
|
+
} else {
|
|
650
|
+
debug("processStreamEvent: unhandled content_block_delta type", event.delta?.type);
|
|
651
|
+
}
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (event?.type === "content_block_stop") {
|
|
656
|
+
const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
|
|
657
|
+
const block = c.turnBlocks[index];
|
|
658
|
+
if (!block) return;
|
|
659
|
+
delete block.index;
|
|
660
|
+
if (block.type === "text") {
|
|
661
|
+
c.currentPiStream!.push({ type: "text_end", contentIndex: index, content: block.text, partial: c.turnOutput });
|
|
662
|
+
} else if (block.type === "thinking") {
|
|
663
|
+
c.currentPiStream!.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: c.turnOutput });
|
|
664
|
+
} else if (block.type === "toolCall") {
|
|
665
|
+
c.turnSawToolCall = true;
|
|
666
|
+
block.arguments = mapToolArgs(
|
|
667
|
+
block.name, parsePartialJson(block.partialJson, block.arguments),
|
|
668
|
+
);
|
|
669
|
+
delete block.partialJson;
|
|
670
|
+
c.currentPiStream!.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: c.turnOutput });
|
|
671
|
+
}
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (event?.type === "message_delta") {
|
|
676
|
+
c.turnOutput.stopReason = mapStopReason(event.delta?.stop_reason);
|
|
677
|
+
if (event.usage) updateUsage(c.turnOutput, event.usage, model);
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (event?.type === "message_stop" && c.turnSawToolCall) {
|
|
682
|
+
// Tool call complete — end this pi stream. The SDK will still yield an
|
|
683
|
+
// assistant message for this turn, but currentPiStream=null causes
|
|
684
|
+
// consumeQuery to skip it. The MCP handler blocks the generator until
|
|
685
|
+
// pi delivers the tool result via the next streamSimple call.
|
|
686
|
+
c.turnOutput.stopReason = "toolUse";
|
|
687
|
+
c.currentPiStream!.push({ type: "done", reason: "toolUse", message: c.turnOutput });
|
|
688
|
+
c.currentPiStream!.end();
|
|
689
|
+
c.currentPiStream = null;
|
|
690
|
+
|
|
691
|
+
// Cursor is updated by the next streamSimple call (tool result delivery path)
|
|
692
|
+
// which sets cursor = context.messages.length with the post-tool-result context.
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (event?.type !== "message_stop" && event?.type !== "ping") {
|
|
697
|
+
debug("processStreamEvent: unhandled event type", event?.type);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// The SDK always yields `assistant` messages (completed content blocks) after streaming.
|
|
702
|
+
// When stream_events already delivered the content, this is a no-op. But after
|
|
703
|
+
// resetTurnState (e.g. tool result delivery), if the next turn's assistant message
|
|
704
|
+
// arrives before any stream_events, this is the primary content path. Must maintain
|
|
705
|
+
// the same stream lifecycle as processStreamEvent — including ending the stream on
|
|
706
|
+
// tool_use to prevent deadlock with the MCP handler.
|
|
707
|
+
function processAssistantMessage(message: SDKMessage, model: Model<any>, customToolNameToPi: Map<string, string>): void {
|
|
708
|
+
const c = ctx();
|
|
709
|
+
if (c.turnSawStreamEvent) return;
|
|
710
|
+
const assistantMsg = (message as any).message;
|
|
711
|
+
if (!assistantMsg?.content) return;
|
|
712
|
+
c.turnToolCallIds = [];
|
|
713
|
+
c.nextHandlerIdx = 0;
|
|
714
|
+
debug(`processAssistantMessage fallback: ${assistantMsg.content.length} blocks, types=${assistantMsg.content.map((b: any) => b.type).join(",")}`);
|
|
715
|
+
for (const block of assistantMsg.content) {
|
|
716
|
+
if (block.type === "text" && block.text) {
|
|
717
|
+
ensureTurnStarted();
|
|
718
|
+
c.turnBlocks.push({ type: "text", text: block.text });
|
|
719
|
+
const idx = c.turnBlocks.length - 1;
|
|
720
|
+
c.currentPiStream?.push({ type: "text_start", contentIndex: idx, partial: c.turnOutput });
|
|
721
|
+
c.currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: block.text, partial: c.turnOutput });
|
|
722
|
+
c.currentPiStream?.push({ type: "text_end", contentIndex: idx, content: block.text, partial: c.turnOutput });
|
|
723
|
+
} else if (block.type === "thinking") {
|
|
724
|
+
ensureTurnStarted();
|
|
725
|
+
c.turnBlocks.push({ type: "thinking", thinking: block.thinking ?? "", thinkingSignature: block.signature ?? "" });
|
|
726
|
+
const idx = c.turnBlocks.length - 1;
|
|
727
|
+
c.currentPiStream?.push({ type: "thinking_start", contentIndex: idx, partial: c.turnOutput });
|
|
728
|
+
if (block.thinking) c.currentPiStream?.push({ type: "thinking_delta", contentIndex: idx, delta: block.thinking, partial: c.turnOutput });
|
|
729
|
+
c.currentPiStream?.push({ type: "thinking_end", contentIndex: idx, content: block.thinking ?? "", partial: c.turnOutput });
|
|
730
|
+
} else if (block.type === "tool_use") {
|
|
731
|
+
ensureTurnStarted();
|
|
732
|
+
c.turnSawToolCall = true;
|
|
733
|
+
c.turnToolCallIds.push(block.id);
|
|
734
|
+
const mappedArgs = mapToolArgs(mapToolName(block.name, customToolNameToPi), block.input);
|
|
735
|
+
c.turnBlocks.push({
|
|
736
|
+
type: "toolCall", id: block.id,
|
|
737
|
+
name: mapToolName(block.name, customToolNameToPi),
|
|
738
|
+
arguments: mappedArgs,
|
|
739
|
+
});
|
|
740
|
+
const idx = c.turnBlocks.length - 1;
|
|
741
|
+
const toolBlock = c.turnBlocks[idx];
|
|
742
|
+
c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
|
|
743
|
+
c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
|
|
744
|
+
} else {
|
|
745
|
+
debug("processAssistantMessage: unhandled block type", block.type);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model);
|
|
749
|
+
|
|
750
|
+
// End the stream on tool_use, same as processStreamEvent's message_stop handler.
|
|
751
|
+
if (c.turnSawToolCall && c.currentPiStream && c.turnOutput) {
|
|
752
|
+
c.turnOutput.stopReason = "toolUse";
|
|
753
|
+
c.currentPiStream.push({ type: "done", reason: "toolUse", message: c.turnOutput });
|
|
754
|
+
c.currentPiStream.end();
|
|
755
|
+
c.currentPiStream = null;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** Background consumer: iterates the SDK generator, pushing events to currentPiStream.
|
|
760
|
+
* Runs until the query ends. Per turn, the SDK yields stream_events (deltas), then
|
|
761
|
+
* an assistant message (completed blocks). On tool_use, the stream is ended by
|
|
762
|
+
* whichever path handles it first (processStreamEvent or processAssistantMessage),
|
|
763
|
+
* and the MCP handler blocks the generator until pi delivers the tool result. */
|
|
764
|
+
async function consumeQuery(
|
|
765
|
+
sdkQuery: ReturnType<typeof query>,
|
|
766
|
+
customToolNameToPi: Map<string, string>,
|
|
767
|
+
model: Model<any>,
|
|
768
|
+
wasAborted: () => boolean,
|
|
769
|
+
): Promise<{ capturedSessionId?: string }> {
|
|
770
|
+
let capturedSessionId: string | undefined;
|
|
771
|
+
|
|
772
|
+
for await (const message of sdkQuery) {
|
|
773
|
+
if (wasAborted()) break;
|
|
774
|
+
if (!ctx().currentPiStream || !ctx().turnOutput) continue;
|
|
775
|
+
|
|
776
|
+
switch (message.type) {
|
|
777
|
+
case "stream_event":
|
|
778
|
+
processStreamEvent(message, customToolNameToPi, model);
|
|
779
|
+
break;
|
|
780
|
+
case "assistant":
|
|
781
|
+
processAssistantMessage(message, model, customToolNameToPi);
|
|
782
|
+
break;
|
|
783
|
+
case "result":
|
|
784
|
+
if (!ctx().turnSawStreamEvent && message.subtype === "success") {
|
|
785
|
+
ensureTurnStarted();
|
|
786
|
+
const text = message.result || "";
|
|
787
|
+
ctx().turnBlocks.push({ type: "text", text });
|
|
788
|
+
const idx = ctx().turnBlocks.length - 1;
|
|
789
|
+
ctx().currentPiStream?.push({ type: "text_start", contentIndex: idx, partial: ctx().turnOutput });
|
|
790
|
+
ctx().currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: text, partial: ctx().turnOutput });
|
|
791
|
+
ctx().currentPiStream?.push({ type: "text_end", contentIndex: idx, content: text, partial: ctx().turnOutput });
|
|
792
|
+
}
|
|
793
|
+
break;
|
|
794
|
+
case "system":
|
|
795
|
+
if ((message as any).subtype === "init" && (message as any).session_id) {
|
|
796
|
+
capturedSessionId = (message as any).session_id;
|
|
797
|
+
}
|
|
798
|
+
break;
|
|
799
|
+
case "user":
|
|
800
|
+
break; // SDK echo of user prompt — not needed
|
|
801
|
+
case "rate_limit_event": {
|
|
802
|
+
const info = (message as any).rate_limit_info;
|
|
803
|
+
debug("consumeQuery: rate_limit_event", JSON.stringify(info).slice(0, 300));
|
|
804
|
+
if (info?.status === "rejected") {
|
|
805
|
+
const resetsAt = info.resetsAt ? new Date(info.resetsAt).toLocaleTimeString() : "unknown";
|
|
806
|
+
piUI?.notify(`Claude rate limited (${info.rateLimitType ?? "unknown"}) — resets at ${resetsAt}`, "warning");
|
|
807
|
+
} else if (info?.status === "allowed_warning") {
|
|
808
|
+
piUI?.notify(`Claude rate limit warning: ${Math.round(info.utilization ?? 0)}% used (${info.rateLimitType ?? ""})`, "warning");
|
|
809
|
+
}
|
|
810
|
+
break;
|
|
811
|
+
}
|
|
812
|
+
default:
|
|
813
|
+
debug("consumeQuery: unhandled SDK message type", message.type);
|
|
814
|
+
break;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// DEBUG: trace when consumeQuery exits
|
|
819
|
+
debug(`consumeQuery: for-await loop exited, wasAborted=${wasAborted()}, capturedSessionId=${capturedSessionId?.slice(0, 8) ?? "none"}`);
|
|
820
|
+
|
|
821
|
+
return { capturedSessionId };
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** Provider entry point. Pi calls this for each new prompt and each tool result.
|
|
825
|
+
* Two cases: tool result delivery (active query) or fresh query. */
|
|
826
|
+
function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
|
|
827
|
+
const stream = newAssistantMessageEventStream();
|
|
828
|
+
|
|
829
|
+
// DEBUG: trace followUp message triggering
|
|
830
|
+
const lastMsgRole = context.messages[context.messages.length - 1]?.role;
|
|
831
|
+
debug(`provider: streamClaudeAgentSdk called, activeQuery=${!!ctx().activeQuery}, lastMsgRole=${lastMsgRole}, isReentrant=${ctx().activeQuery !== null}`);
|
|
832
|
+
|
|
833
|
+
// --- Tool result delivery ---
|
|
834
|
+
// Pi appends tool results to context and calls back. Extract this turn's results
|
|
835
|
+
// (everything after the last assistant message) and match against waiting MCP
|
|
836
|
+
// handlers. Results that arrive before their handler get queued in pendingResults.
|
|
837
|
+
if (ctx().activeQuery) {
|
|
838
|
+
ctx().currentPiStream = stream;
|
|
839
|
+
ctx().resetTurnState(model);
|
|
840
|
+
const allResults = extractAllToolResults(context);
|
|
841
|
+
debug(`provider: tool results, ${allResults.length} results, ${ctx().pendingToolCalls.size} waiting handlers, ctx.msgs=${context.messages.length}`);
|
|
842
|
+
for (const result of allResults) {
|
|
843
|
+
const id = result.toolCallId;
|
|
844
|
+
if (id && ctx().pendingToolCalls.has(id)) {
|
|
845
|
+
const pending = ctx().pendingToolCalls.get(id)!;
|
|
846
|
+
ctx().pendingToolCalls.delete(id);
|
|
847
|
+
debug(`provider: resolving ${pending.toolName} [${id}]${result.isError ? " (error)" : ""}`, JSON.stringify(result.content).slice(0, 200));
|
|
848
|
+
pending.resolve(result);
|
|
849
|
+
} else if (id) {
|
|
850
|
+
ctx().pendingResults.set(id, result);
|
|
851
|
+
debug(`provider: queued result [${id}] (${ctx().pendingResults.size} pending)`);
|
|
852
|
+
} else {
|
|
853
|
+
debug(`WARNING: tool result without toolCallId, cannot match`);
|
|
854
|
+
}
|
|
855
|
+
if (ctx().pendingToolCalls.size > 0 && ctx().pendingResults.size > 0) {
|
|
856
|
+
debug(`BUG: both maps non-empty! handlers=${ctx().pendingToolCalls.size} results=${ctx().pendingResults.size}`);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
if (ctx().pendingToolCalls.size > 0) {
|
|
860
|
+
debug(`WARNING: ${ctx().pendingToolCalls.size} MCP handlers still waiting after delivering ${allResults.length} results`);
|
|
861
|
+
piUI?.notify(`Claude bridge: ${ctx().pendingToolCalls.size} tool handler(s) still waiting — provider may be stuck`, "warning");
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Detect user messages (steer/followUp) that pi injected into context
|
|
865
|
+
// during the active query. This happens when:
|
|
866
|
+
// - User sends a steer while a tool is executing; pi drains the steer
|
|
867
|
+
// queue at the turn boundary and appends it to context alongside the
|
|
868
|
+
// tool result, then calls the provider again.
|
|
869
|
+
// - A followUp is delivered between tool-result turns.
|
|
870
|
+
// The bridge can't forward these mid-query (the SDK query is in progress),
|
|
871
|
+
// so we save them for replay as continuation queries after consumeQuery ends.
|
|
872
|
+
if (lastMsgRole === "user") {
|
|
873
|
+
const userPrompt = extractUserPrompt(context.messages);
|
|
874
|
+
if (userPrompt) {
|
|
875
|
+
ctx().deferredUserMessages.push(userPrompt);
|
|
876
|
+
debug(`provider: deferred user message for replay after query: ${userPrompt.slice(0, 60)}`);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
if (sharedSession) sharedSession.cursor = context.messages.length;
|
|
881
|
+
ctx().latestCursor = Math.max(ctx().latestCursor, context.messages.length);
|
|
882
|
+
return stream;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// --- Orphaned tool result (e.g. user aborted a tool call) ---
|
|
886
|
+
// The query is gone but pi still delivered the result. Nothing to do — just
|
|
887
|
+
// emit end_turn so pi waits for the next real user message.
|
|
888
|
+
const lastMsg = context.messages[context.messages.length - 1];
|
|
889
|
+
if (lastMsg?.role === "toolResult") {
|
|
890
|
+
debug(`provider: orphaned tool result after abort, emitting end_turn`);
|
|
891
|
+
if (sharedSession) sharedSession.cursor = context.messages.length;
|
|
892
|
+
const c = ctx(); // capture current context for the microtask
|
|
893
|
+
queueMicrotask(() => {
|
|
894
|
+
c.resetTurnState(model);
|
|
895
|
+
stream.push({ type: "done", reason: "stop", message: c.turnOutput });
|
|
896
|
+
stream.end();
|
|
897
|
+
});
|
|
898
|
+
return stream;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// --- Fresh query ---
|
|
902
|
+
|
|
903
|
+
// 1. Determine reentrancy and push parent context if needed.
|
|
904
|
+
const isReentrant = ctx().activeQuery !== null;
|
|
905
|
+
if (isReentrant) pushContext();
|
|
906
|
+
debug(`provider: fresh query setup, isReentrant=${isReentrant}, stackDepth=${stackDepth()}`);
|
|
907
|
+
|
|
908
|
+
// 2. Fresh child context — constructor already gave us clean Maps and empty
|
|
909
|
+
// arrays. For a reused top-level context, clear explicitly.
|
|
910
|
+
ctx().currentPiStream = stream;
|
|
911
|
+
ctx().pendingToolCalls.clear();
|
|
912
|
+
ctx().pendingResults.clear();
|
|
913
|
+
ctx().deferredUserMessages = [];
|
|
914
|
+
ctx().resetTurnState(model);
|
|
915
|
+
ctx().latestCursor = 0;
|
|
916
|
+
|
|
917
|
+
const { mcpTools, customToolNameToSdk, customToolNameToPi } = resolveMcpTools(context);
|
|
918
|
+
const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
|
|
919
|
+
const { sessionId: resumeSessionId } = syncSharedSession(context.messages, cwd, customToolNameToSdk, model.id);
|
|
920
|
+
const promptBlocks = extractUserPromptBlocks(context.messages);
|
|
921
|
+
let promptText = extractUserPrompt(context.messages) ?? "";
|
|
922
|
+
|
|
923
|
+
// Guard: empty prompt means the last context message isn't a user message.
|
|
924
|
+
// This should never happen with the state stack fix — dump diagnostics if it does.
|
|
925
|
+
if (!promptText && !promptBlocks) {
|
|
926
|
+
diagDump("empty_prompt", {
|
|
927
|
+
contextLength: context.messages.length,
|
|
928
|
+
lastMsgRole: lastMsg?.role,
|
|
929
|
+
isReentrant,
|
|
930
|
+
stackDepth: stackDepth(),
|
|
931
|
+
activeQueryExists: ctx().activeQuery !== null,
|
|
932
|
+
sharedSession: sharedSession ? { sessionId: sharedSession.sessionId.slice(0, 8), cursor: sharedSession.cursor } : null,
|
|
933
|
+
messageRoles: context.messages.map((m, i) => `[${i}]${m.role}`).join(" "),
|
|
934
|
+
});
|
|
935
|
+
// Recover: use a continuation prompt so the SDK doesn't send an empty text block
|
|
936
|
+
promptText = "[continue]";
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const prompt: string | AsyncIterable<SDKUserMessage> = promptBlocks
|
|
940
|
+
? wrapPromptStream(promptBlocks)
|
|
941
|
+
: promptText;
|
|
942
|
+
const mcpServers = buildMcpServers(mcpTools, ctx());
|
|
943
|
+
const bridgeConfig = loadConfig(cwd);
|
|
944
|
+
const providerSettings = bridgeConfig.provider ?? {};
|
|
945
|
+
const appendSystemPrompt = providerSettings.appendSystemPrompt !== false;
|
|
946
|
+
const agentsAppend = appendSystemPrompt ? extractAgentsAppend() : undefined;
|
|
947
|
+
const skillsAppend = appendSystemPrompt ? extractSkillsBlock(context.systemPrompt) : undefined;
|
|
948
|
+
const promptContextAppend = buildPromptContextAppend(context.systemPrompt, cwd, bridgeConfig.promptContext ?? {});
|
|
949
|
+
const appendParts = [agentsAppend, skillsAppend, promptContextAppend.text].filter((part): part is string => Boolean(part));
|
|
950
|
+
const systemPromptAppend = appendParts.length > 0 ? appendParts.join("\n\n") : undefined;
|
|
951
|
+
|
|
952
|
+
// MCP auto-loading suppression: with appendSystemPrompt=true (default), the
|
|
953
|
+
// SDK uses isolation mode and avoids filesystem settings. If users turn that
|
|
954
|
+
// off, load user/project settings but pass --strict-mcp-config so Claude Code
|
|
955
|
+
// ignores auto-discovered filesystem MCP servers while Pi owns tool execution.
|
|
956
|
+
const settingSources: SettingSource[] | undefined = appendSystemPrompt
|
|
957
|
+
? undefined
|
|
958
|
+
: providerSettings.settingSources ?? ["user", "project"];
|
|
959
|
+
const strictMcpConfigEnabled = !appendSystemPrompt && providerSettings.strictMcpConfig !== false;
|
|
960
|
+
const claudeExecutable = resolveClaudeExecutable(providerSettings.pathToClaudeCodeExecutable);
|
|
961
|
+
|
|
962
|
+
// Prefer the model's own thinkingLevelMap when present (pi-ai 0.72+ ships
|
|
963
|
+
// per-model overrides — e.g. opus-4-7 wants xhigh→xhigh, not xhigh→max).
|
|
964
|
+
// Fall back to our generic table for older pi-ai or unmapped levels.
|
|
965
|
+
const effort = options?.reasoning
|
|
966
|
+
? ((model as any).thinkingLevelMap?.[options.reasoning] as EffortLevel | undefined)
|
|
967
|
+
?? REASONING_TO_EFFORT[options.reasoning]
|
|
968
|
+
: undefined;
|
|
969
|
+
|
|
970
|
+
const extraArgs: Record<string, string | null> = { model: model.id };
|
|
971
|
+
if (strictMcpConfigEnabled) extraArgs["strict-mcp-config"] = null;
|
|
972
|
+
// Opus 4.7 defaults thinking.display to "omitted" (empty thinking text in stream).
|
|
973
|
+
// Force summarized so thinking_delta events arrive. See anthropics/claude-agent-sdk-python#830.
|
|
974
|
+
if (effort) extraArgs["thinking-display"] = "summarized";
|
|
975
|
+
|
|
976
|
+
// Suppress claude.ai cloud MCP servers (Figma/Canva/etc. auto-discovered via OAuth
|
|
977
|
+
// when the user is logged into Anthropic). These are a separate code path from
|
|
978
|
+
// filesystem MCP and are NOT blocked by --strict-mcp-config or settingSources=undefined.
|
|
979
|
+
// The native CC binary gates them on env var ENABLE_CLAUDEAI_MCP_SERVERS: setting it
|
|
980
|
+
// to "0"/"false"/"no"/"off" makes the loader return early before any cloud fetch.
|
|
981
|
+
// DISABLE_AUTO_COMPACT=1: pi owns context-management and propagates its own
|
|
982
|
+
// /compact via session_compact (see handler in default export). Letting CC
|
|
983
|
+
// also autocompact would double-flush the prompt cache and races pi's
|
|
984
|
+
// threshold with CC's, including CC's anti-thrashing guard (issue #8).
|
|
985
|
+
// Manual /compact in CC still works (we never invoke it).
|
|
986
|
+
const childEnv = { ...process.env, ENABLE_CLAUDEAI_MCP_SERVERS: "0", DISABLE_AUTO_COMPACT: "1" };
|
|
987
|
+
const queryOptions: NonNullable<Parameters<typeof query>[0]["options"]> = {
|
|
988
|
+
cwd,
|
|
989
|
+
env: childEnv,
|
|
990
|
+
disallowedTools: DISALLOWED_BUILTIN_TOOLS,
|
|
991
|
+
allowedTools: [`mcp__${MCP_SERVER_NAME}__*`],
|
|
992
|
+
permissionMode: "bypassPermissions",
|
|
993
|
+
includePartialMessages: true,
|
|
994
|
+
systemPrompt: {
|
|
995
|
+
type: "preset", preset: "claude_code",
|
|
996
|
+
append: systemPromptAppend ? systemPromptAppend : undefined,
|
|
997
|
+
},
|
|
998
|
+
extraArgs,
|
|
999
|
+
...(effort ? { effort } : {}),
|
|
1000
|
+
...(settingSources ? { settingSources } : {}),
|
|
1001
|
+
...(mcpServers ? { mcpServers } : {}),
|
|
1002
|
+
...(resumeSessionId ? { resume: resumeSessionId } : {}),
|
|
1003
|
+
...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
|
|
1004
|
+
...makeCliDebugOptions("provider"),
|
|
1005
|
+
};
|
|
1006
|
+
|
|
1007
|
+
debug("provider: fresh query",
|
|
1008
|
+
`model=${model.id} msgs=${context.messages.length} tools=${mcpTools.length}`,
|
|
1009
|
+
`resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
|
|
1010
|
+
`appendSys=${appendSystemPrompt} promptCtx=${promptContextAppend.labels.join(",") || "none"} strictMcp=${strictMcpConfigEnabled}`,
|
|
1011
|
+
`prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`);
|
|
1012
|
+
|
|
1013
|
+
// 3. Start SDK query and claim it for this context
|
|
1014
|
+
let wasAborted = false;
|
|
1015
|
+
const sdkQuery = query({ prompt, options: queryOptions });
|
|
1016
|
+
ctx().activeQuery = sdkQuery;
|
|
1017
|
+
|
|
1018
|
+
// 4. Capture context for abort handling (must be AFTER pushContext)
|
|
1019
|
+
const abortCtx = ctx();
|
|
1020
|
+
|
|
1021
|
+
const requestAbort = () => {
|
|
1022
|
+
// interrupt() asks the CLI to stop gracefully; close() kills it immediately.
|
|
1023
|
+
// Both are needed — interrupt alone lets the current API call finish.
|
|
1024
|
+
void sdkQuery.interrupt().catch(() => {});
|
|
1025
|
+
try { sdkQuery.close(); } catch {}
|
|
1026
|
+
};
|
|
1027
|
+
const onAbort = () => {
|
|
1028
|
+
wasAborted = true;
|
|
1029
|
+
// Prevent stale deferred messages from being replayed by parent on pop
|
|
1030
|
+
abortCtx.deferredUserMessages = [];
|
|
1031
|
+
for (const pending of abortCtx.pendingToolCalls.values()) { pending.resolve({ content: [{ type: "text", text: "Operation aborted" }] }); }
|
|
1032
|
+
abortCtx.pendingToolCalls.clear();
|
|
1033
|
+
abortCtx.pendingResults.clear();
|
|
1034
|
+
requestAbort();
|
|
1035
|
+
};
|
|
1036
|
+
if (options?.signal) {
|
|
1037
|
+
if (options.signal.aborted) onAbort();
|
|
1038
|
+
else options.signal.addEventListener("abort", onAbort, { once: true });
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// Background consumer — runs until query ends
|
|
1042
|
+
consumeQuery(sdkQuery, customToolNameToPi, model, () => wasAborted)
|
|
1043
|
+
.then(async ({ capturedSessionId }) => {
|
|
1044
|
+
debug(`provider: consumeQuery completed, stopReason=${ctx().turnOutput?.stopReason}, error=${ctx().turnOutput?.errorMessage}, aborted=${wasAborted}`);
|
|
1045
|
+
|
|
1046
|
+
// --- Abort detection in normal completion path ---
|
|
1047
|
+
if (wasAborted || options?.signal?.aborted) {
|
|
1048
|
+
if (sharedSession) sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
|
|
1049
|
+
ctx().deferredUserMessages = [];
|
|
1050
|
+
debug(`provider: abort detected, marked sharedSession needsRebuild + forceRotate`);
|
|
1051
|
+
if (ctx().turnOutput) {
|
|
1052
|
+
ctx().turnOutput.stopReason = "aborted";
|
|
1053
|
+
ctx().turnOutput.errorMessage = "Operation aborted";
|
|
1054
|
+
}
|
|
1055
|
+
ctx().currentPiStream?.push({ type: "error", reason: "aborted", error: ctx().turnOutput! });
|
|
1056
|
+
ctx().currentPiStream?.end();
|
|
1057
|
+
ctx().currentPiStream = null;
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// --- Capture session ID ---
|
|
1062
|
+
const sessionId = capturedSessionId ?? sharedSession?.sessionId;
|
|
1063
|
+
if (sessionId) {
|
|
1064
|
+
const cursor = Math.max(context.messages.length, ctx().latestCursor, sharedSession?.cursor ?? 0);
|
|
1065
|
+
debug(`provider: query done, session=${sessionId.slice(0, 8)}, cursor=${cursor}`);
|
|
1066
|
+
sharedSession = { sessionId, cursor, cwd };
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// --- Replay deferred user messages as continuation queries ---
|
|
1070
|
+
// Only for outermost queries — reentrant (subagent) queries leave
|
|
1071
|
+
// deferred messages for the parent to handle after it finishes.
|
|
1072
|
+
try {
|
|
1073
|
+
while (ctx().deferredUserMessages.length > 0 && !isReentrant && !wasAborted) {
|
|
1074
|
+
const steerPrompt = ctx().deferredUserMessages.shift()!;
|
|
1075
|
+
debug(`provider: replaying deferred user message: ${steerPrompt.slice(0, 60)}`);
|
|
1076
|
+
ctx().resetTurnState(model);
|
|
1077
|
+
|
|
1078
|
+
const resumeId = sharedSession?.sessionId;
|
|
1079
|
+
if (!resumeId) {
|
|
1080
|
+
debug(`WARNING: no session to resume for deferred message, dropping`);
|
|
1081
|
+
break;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
const contOptions = { ...queryOptions, resume: resumeId, ...makeCliDebugOptions("continuation") };
|
|
1085
|
+
const contQuery = query({ prompt: steerPrompt, options: contOptions });
|
|
1086
|
+
ctx().activeQuery = contQuery;
|
|
1087
|
+
|
|
1088
|
+
debug(`provider: continuation query, model=${model.id}, resume=${resumeId.slice(0, 8)}, prompt=${steerPrompt.slice(0, 60)}`);
|
|
1089
|
+
|
|
1090
|
+
try {
|
|
1091
|
+
const { capturedSessionId: contSid } = await consumeQuery(contQuery, customToolNameToPi, model, () => wasAborted);
|
|
1092
|
+
const sid = contSid ?? sharedSession?.sessionId;
|
|
1093
|
+
if (sid) {
|
|
1094
|
+
sharedSession = { sessionId: sid, cursor: sharedSession?.cursor ?? 0, cwd };
|
|
1095
|
+
}
|
|
1096
|
+
} catch (contError) {
|
|
1097
|
+
debug(`provider: continuation query error:`, contError);
|
|
1098
|
+
break;
|
|
1099
|
+
} finally {
|
|
1100
|
+
contQuery.close();
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
} finally {
|
|
1104
|
+
// Guarantees restoration even if contQuery() throws synchronously
|
|
1105
|
+
ctx().activeQuery = sdkQuery;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
finalizeCurrentStream(ctx().turnOutput?.stopReason);
|
|
1109
|
+
})
|
|
1110
|
+
.catch((error) => {
|
|
1111
|
+
debug(`provider: query error, model=${model.id}, aborted=${Boolean(options?.signal?.aborted)}, error=`, error);
|
|
1112
|
+
if ((wasAborted || options?.signal?.aborted) && sharedSession) {
|
|
1113
|
+
sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
|
|
1114
|
+
} else {
|
|
1115
|
+
sharedSession = null;
|
|
1116
|
+
}
|
|
1117
|
+
ctx().deferredUserMessages = [];
|
|
1118
|
+
if (ctx().turnOutput) {
|
|
1119
|
+
ctx().turnOutput.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
1120
|
+
ctx().turnOutput.errorMessage = error instanceof Error ? error.message : String(error);
|
|
1121
|
+
}
|
|
1122
|
+
ctx().currentPiStream?.push({ type: "error", reason: (ctx().turnOutput?.stopReason ?? "error") as "aborted" | "error", error: ctx().turnOutput! });
|
|
1123
|
+
ctx().currentPiStream?.end();
|
|
1124
|
+
ctx().currentPiStream = null;
|
|
1125
|
+
})
|
|
1126
|
+
.finally(() => {
|
|
1127
|
+
if (options?.signal) options.signal.removeEventListener("abort", onAbort);
|
|
1128
|
+
if (ctx().activeQuery === sdkQuery) {
|
|
1129
|
+
// Drain pending handlers for this query
|
|
1130
|
+
for (const pending of ctx().pendingToolCalls.values()) { pending.resolve({ content: [{ type: "text", text: "Query ended" }] }); }
|
|
1131
|
+
ctx().pendingToolCalls.clear();
|
|
1132
|
+
ctx().pendingResults.clear();
|
|
1133
|
+
|
|
1134
|
+
if (isReentrant) {
|
|
1135
|
+
popContext(); // merges deferred messages and restores parent
|
|
1136
|
+
} else {
|
|
1137
|
+
ctx().activeQuery = null;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
sdkQuery.close();
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
return stream;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// --- Extension registration ---
|
|
1147
|
+
|
|
1148
|
+
export default function (pi: ExtensionAPI) {
|
|
1149
|
+
// Disable non-essential Claude Code traffic (update checks, MCP registry, telemetry)
|
|
1150
|
+
process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
|
1151
|
+
|
|
1152
|
+
const config = loadConfig(process.cwd());
|
|
1153
|
+
debug("loadConfig:", JSON.stringify(config));
|
|
1154
|
+
if (config.enabled === false) {
|
|
1155
|
+
debug("provider: disabled by configuration");
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// Reset shared session on pi session lifecycle events
|
|
1160
|
+
const clearSession = (event: string) => {
|
|
1161
|
+
debug(`${event}: clearing session ${sharedSession?.sessionId?.slice(0, 8) ?? "none"}`);
|
|
1162
|
+
sharedSession = null;
|
|
1163
|
+
|
|
1164
|
+
// Clear the global streamSimple if this instance registered it.
|
|
1165
|
+
// This allows /reload to work — the old instance clears the flag so
|
|
1166
|
+
// the new instance can register fresh without wrapping stale state.
|
|
1167
|
+
const g = globalThis as Record<symbol, any>;
|
|
1168
|
+
if (g[ACTIVE_STREAM_SIMPLE_KEY] === streamClaudeAgentSdk) {
|
|
1169
|
+
debug(`${event}: clearing ACTIVE_STREAM_SIMPLE_KEY`);
|
|
1170
|
+
g[ACTIVE_STREAM_SIMPLE_KEY] = undefined;
|
|
1171
|
+
}
|
|
1172
|
+
};
|
|
1173
|
+
pi.on("session_start", (event, ctx) => {
|
|
1174
|
+
piUI = ctx.ui;
|
|
1175
|
+
if (event.reason === "new" || event.reason === "resume" || event.reason === "fork") {
|
|
1176
|
+
clearSession(`session_start:${event.reason}`);
|
|
1177
|
+
}
|
|
1178
|
+
});
|
|
1179
|
+
pi.on("session_shutdown", () => clearSession("session_shutdown"));
|
|
1180
|
+
|
|
1181
|
+
// pi /compact and session-tree navigation (rewind / fork-at-point /
|
|
1182
|
+
// branch switch) both mutate pi's messages array out from under the
|
|
1183
|
+
// bridge. syncSharedSession's REUSE check would otherwise see
|
|
1184
|
+
// slice(cursor) === [] (or skip entries) and keep --resume'ing a CC
|
|
1185
|
+
// session that no longer matches pi's history. /compact in particular
|
|
1186
|
+
// triggers CC's autocompact-thrashing guard (issue #8). Force the next
|
|
1187
|
+
// call down the REBUILD path so CC sees the current history.
|
|
1188
|
+
const markRebuild = (event: string) => {
|
|
1189
|
+
if (sharedSession) {
|
|
1190
|
+
debug(`${event}: marking needsRebuild on session ${sharedSession.sessionId.slice(0, 8)}`);
|
|
1191
|
+
sharedSession = { ...sharedSession, needsRebuild: true };
|
|
1192
|
+
}
|
|
1193
|
+
};
|
|
1194
|
+
pi.on("session_compact", () => markRebuild("session_compact"));
|
|
1195
|
+
pi.on("session_tree", () => markRebuild("session_tree"));
|
|
1196
|
+
|
|
1197
|
+
// --- Provider ---
|
|
1198
|
+
//
|
|
1199
|
+
// Guard against re-registration when the module is loaded multiple times
|
|
1200
|
+
// (e.g., when spawning subagents). The shared ModelRegistry would otherwise
|
|
1201
|
+
// overwrite the parent's streamSimple, breaking tool result delivery.
|
|
1202
|
+
// See ACTIVE_STREAM_SIMPLE_KEY for the full mechanism.
|
|
1203
|
+
|
|
1204
|
+
const g = globalThis as Record<symbol, any>;
|
|
1205
|
+
if (!g[ACTIVE_STREAM_SIMPLE_KEY]) {
|
|
1206
|
+
// First instance: store our streamSimple and register.
|
|
1207
|
+
g[ACTIVE_STREAM_SIMPLE_KEY] = streamClaudeAgentSdk;
|
|
1208
|
+
pi.registerProvider(PROVIDER_ID, {
|
|
1209
|
+
baseUrl: "claude-bridge",
|
|
1210
|
+
apiKey: "not-used",
|
|
1211
|
+
api: "claude-bridge",
|
|
1212
|
+
models: MODELS,
|
|
1213
|
+
// Cast: pi-ai AssistantMessageEventStream diamond dep between pi-coding-agent and pi-agent-core
|
|
1214
|
+
streamSimple: streamClaudeAgentSdk as any,
|
|
1215
|
+
});
|
|
1216
|
+
} else {
|
|
1217
|
+
// Subsequent instance (subagent session): skip registration entirely.
|
|
1218
|
+
// The subagent already has access to claude-bridge models via the shared
|
|
1219
|
+
// ModelRegistry from the parent's registration. Calls to those models
|
|
1220
|
+
// will route through the parent's streamSimple via the reentrant
|
|
1221
|
+
// QueryContext stack mechanism.
|
|
1222
|
+
debug(`provider: skipping re-registration, parent instance active (module=${moduleInstanceId})`);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
}
|