@raoxxxwq/pi-codebuddy-sdk 0.3.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/index.ts ADDED
@@ -0,0 +1,1979 @@
1
+ import { calculateCost, StringEnum, 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 { buildSessionContext, compact, keyHint, type CompactionEntry, type ExtensionAPI, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
4
+ import { createSdkMcpServer, query, type Effort, type Message as CbMessage, type UserMessage as CbUserMessage } from "@tencent-ai/agent-sdk";
5
+ import { Type } from "typebox";
6
+ import { Text } from "@earendil-works/pi-tui";
7
+ import { createSession, deleteSession } from "./cb-session-io.js";
8
+ import { appendFileSync, mkdirSync, realpathSync, statSync } from "fs";
9
+ import { homedir } from "os";
10
+ import { dirname, join } from "path";
11
+ import { PROVIDER_ID, messageContentToText, convertPiMessages } from "./convert.js";
12
+ import { buildModels, codebuddyModelId, FALLBACK_MODELS, rawModelsFromSdk, resolveModel as _resolveModel, type PiModel } from "./models.js";
13
+ import { MCP_SERVER_NAME, MCP_TOOL_PREFIX, buildCodebuddySystemPrompt, enhancePiToolForCodebuddy } from "./skills.js";
14
+ import { verifyWrittenSession as _verifyWrittenSession } from "./session-verify.js";
15
+ import { extractAllToolResults as _extractAllToolResults, type McpResult } from "./extract-tool-results.js";
16
+ import { QueryContext, ctx } from "./query-state.js";
17
+ import { loadConfig, type Config } from "./config.js";
18
+ import { jsonSchemaToZodObject } from "./typebox-to-zod.js";
19
+ import { buildActionSummary, type ToolCallState } from "./askcodebuddy-ui.js";
20
+ import {
21
+ applyContextWindowCalibrations,
22
+ buildCalibrationEnvironment,
23
+ loadCalibrationCache,
24
+ recordObservedContextWindow,
25
+ saveCalibrationCache,
26
+ type CalibrationCache,
27
+ type CalibrationEnvironment,
28
+ } from "./model-calibration.js";
29
+ import { withSdkGate } from "./sdk-gate.js";
30
+
31
+ // Compat (#2): use factory if available (pi-ai ≥0.66), else fall back to constructor (gsd-pi etc.)
32
+ const _piAi = piAi as any;
33
+ const newAssistantMessageEventStream: () => AssistantMessageEventStream =
34
+ typeof _piAi.createAssistantMessageEventStream === "function"
35
+ ? _piAi.createAssistantMessageEventStream
36
+ : () => new _piAi.AssistantMessageEventStream();
37
+
38
+ type SdkQueryOptions = NonNullable<Parameters<typeof query>[0]["options"]>;
39
+ type CliDebugOptions = { debug?: boolean; debugFile?: string };
40
+
41
+ // --- Debug logging ---
42
+ // CODEBUDDY_SDK_DEBUG=1 enables local debug logs (metadata only; paths redacted; no prompt/tool bodies).
43
+
44
+ const DEBUG = process.env.CODEBUDDY_SDK_DEBUG === "1";
45
+ const DEBUG_LOG_PATH = process.env.CODEBUDDY_SDK_DEBUG_PATH || join(homedir(), ".pi", "agent", "codebuddy-sdk.log");
46
+ const DIAG_LOG_PATH = join(homedir(), ".pi", "agent", "codebuddy-sdk-diag.log");
47
+ const ISSUES_URL = "https://github.com/MuJianxuan/pi-codebuddy-sdk/issues/new";
48
+
49
+ function redactForLog(value: string): string {
50
+ const home = homedir();
51
+ return home && value.includes(home) ? value.split(home).join("~") : value;
52
+ }
53
+
54
+ function redactLogValue(value: unknown): unknown {
55
+ if (typeof value === "string") return redactForLog(value);
56
+ if (Array.isArray(value)) return value.map(redactLogValue);
57
+ if (value && typeof value === "object") {
58
+ const out: Record<string, unknown> = {};
59
+ for (const [k, v] of Object.entries(value)) out[k] = redactLogValue(v);
60
+ return out;
61
+ }
62
+ return value;
63
+ }
64
+
65
+ // Ensure log directories exist when debug is enabled
66
+ if (DEBUG) {
67
+ try {
68
+ mkdirSync(dirname(DEBUG_LOG_PATH), { recursive: true });
69
+ mkdirSync(dirname(DIAG_LOG_PATH), { recursive: true });
70
+ } catch {
71
+ // If directory creation fails, debug functions will throw on first use
72
+ }
73
+ }
74
+
75
+ // Unique per module evaluation — confirms whether subagents share module state
76
+ const moduleInstanceId = Math.random().toString(36).slice(2, 8);
77
+
78
+ function debug(...args: unknown[]) {
79
+ if (!DEBUG) return;
80
+ const ts = new Date().toISOString();
81
+ const fmt = (a: unknown): string => {
82
+ if (typeof a === "string") return redactForLog(a);
83
+ if (a instanceof Error) return redactForLog(`${a.name}: ${a.message}`);
84
+ return redactForLog(JSON.stringify(a));
85
+ };
86
+ const msg = args.map(fmt).join(" ");
87
+ appendFileSync(DEBUG_LOG_PATH, `[${ts}] [${moduleInstanceId}] ${msg}\n`);
88
+ }
89
+
90
+ // Per-query CLI debug capture. When CODEBUDDY_SDK_DEBUG=1, ask the CodeBuddy CLI
91
+ // subprocess to write its own debug log locally. We do not forward stderr (may
92
+ // contain prompt or credential hints).
93
+ let nextCliDebugSeq = 1;
94
+ function makeCliDebugOptions(tag: string): { debug?: boolean; debugFile?: string } {
95
+ if (!DEBUG) return {};
96
+ const seq = nextCliDebugSeq++;
97
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
98
+ const logDir = join(dirname(DEBUG_LOG_PATH), "cb-cli-logs");
99
+ try { mkdirSync(logDir, { recursive: true }); } catch { /* ignore */ }
100
+ const debugFile = join(logDir, `${ts}-${tag}-${seq}.log`);
101
+ debug(`cli-debug: ${tag} #${seq} → ${redactForLog(debugFile)}`);
102
+ return { debug: true, debugFile };
103
+ }
104
+
105
+ /** Diagnostic dump for "should never happen" paths — only when debug is enabled */
106
+ function diagDump(label: string, data: Record<string, unknown>) {
107
+ if (!DEBUG) return;
108
+ const ts = new Date().toISOString();
109
+ const entry = { ts, moduleInstanceId, label, ...redactLogValue(data) as Record<string, unknown> };
110
+ appendFileSync(DIAG_LOG_PATH, JSON.stringify(entry) + "\n");
111
+ debug(`DIAG: ${label} (see ${redactForLog(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("codebuddy-sdk:activeStreamSimple");
131
+
132
+ const SDK_TO_PI_TOOL_NAME: Record<string, string> = {
133
+ read: "read", write: "write", edit: "edit", bash: "bash",
134
+ };
135
+
136
+ let MODELS: PiModel[] = buildModels(FALLBACK_MODELS);
137
+ let providerSettings: NonNullable<Config["provider"]> = {};
138
+ let calibrationEnvironment: CalibrationEnvironment = buildCalibrationEnvironment();
139
+ let calibrationCache: CalibrationCache = loadCalibrationCache();
140
+ let calibrationRefreshPending = false;
141
+
142
+ type ContentBlockParam =
143
+ | { type: "text"; text: string }
144
+ | { type: "image"; source: { type: "base64"; media_type: string; data: string } };
145
+ type SettingSource = "user" | "project" | "local";
146
+
147
+ function resolveModel(input: string) {
148
+ return _resolveModel(MODELS, input);
149
+ }
150
+
151
+ function applyModelCalibrations(models: PiModel[]): PiModel[] {
152
+ return buildModels(applyContextWindowCalibrations(models, calibrationCache, calibrationEnvironment));
153
+ }
154
+
155
+ function registerCurrentProvider(pi: ExtensionAPI): void {
156
+ const g = globalThis as Record<symbol, any>;
157
+ const streamFn = g[ACTIVE_STREAM_SIMPLE_KEY] ?? streamCodebuddySdk;
158
+ pi.registerProvider(PROVIDER_ID, {
159
+ name: "CodeBuddy",
160
+ baseUrl: PROVIDER_ID,
161
+ apiKey: "not-used",
162
+ api: "codebuddy-sdk",
163
+ models: MODELS as any,
164
+ streamSimple: streamFn as any,
165
+ });
166
+ }
167
+
168
+ function scheduleCalibrationRefresh(reason: string): void {
169
+ calibrationRefreshPending = true;
170
+ debug(`calibration: scheduled provider refresh (${reason})`);
171
+ queueMicrotask(() => maybeRefreshProviderRegistration(`microtask:${reason}`));
172
+ }
173
+
174
+ function maybeRefreshProviderRegistration(reason: string): void {
175
+ if (!calibrationRefreshPending || !piApi) return;
176
+ if (activeQueryContexts.size > 0) {
177
+ debug(`calibration: refresh deferred (${reason}) activeQueries=${activeQueryContexts.size}`);
178
+ return;
179
+ }
180
+ calibrationRefreshPending = false;
181
+ try {
182
+ debug(`calibration: refreshing provider registration (${reason}) models=${MODELS.length}`);
183
+ registerCurrentProvider(piApi);
184
+ } catch (err) {
185
+ debug(`calibration: provider refresh failed (${reason})`, err);
186
+ }
187
+ }
188
+
189
+ // --- Error handling ---
190
+
191
+ function errorMessage(err: unknown): string {
192
+ if (err instanceof Error) return err.message;
193
+ if (err && typeof err === "object") {
194
+ const obj = err as Record<string, unknown>;
195
+ if (typeof obj.message === "string") return obj.message;
196
+ if (typeof obj.error === "string") return obj.error;
197
+ try { return JSON.stringify(err); } catch {}
198
+ }
199
+ return String(err);
200
+ }
201
+
202
+ // AskCodebuddy mode presets — controls which CC tools are blocked per mode.
203
+ // Only block tools that can't work (no pi TUI for user interaction).
204
+ // Other CC tools (Agent, SendMessage, RemoteTrigger, Tasks, etc.) are intentionally not blocked.
205
+ const ASKCLAUDE_ALWAYS_BLOCKED = [
206
+ "AskUserQuestion", "EnterPlanMode", "ExitPlanMode",
207
+ "ToolSearch", // probes for blocked tools, wastes tokens
208
+ "ScheduleWakeup", // no harness to fire wakeup from inside a delegated subagent
209
+ ];
210
+ const MODE_DISALLOWED_TOOLS: Record<string, string[]> = {
211
+ full: [
212
+ ...ASKCLAUDE_ALWAYS_BLOCKED,
213
+ ],
214
+ read: [
215
+ ...ASKCLAUDE_ALWAYS_BLOCKED,
216
+ "Write", "Edit", "Bash", "NotebookEdit",
217
+ "EnterWorktree", "ExitWorktree", "CronCreate", "CronDelete", "TeamCreate", "TeamDelete",
218
+ ],
219
+ none: [
220
+ ...ASKCLAUDE_ALWAYS_BLOCKED,
221
+ "Read", "Write", "Edit", "Glob", "Grep", "Bash", "Agent",
222
+ "NotebookEdit", "EnterWorktree", "ExitWorktree",
223
+ "CronCreate", "CronDelete", "TeamCreate", "TeamDelete",
224
+ "WebFetch", "WebSearch",
225
+ ],
226
+ };
227
+
228
+ // --- Session persistence ---
229
+
230
+ interface SessionState {
231
+ sessionId: string;
232
+ cursor: number;
233
+ cwd: string;
234
+ // Force the next syncSharedSession call down the REBUILD path. Set when
235
+ // pi has mutated its messages array out from under us (compact, tree
236
+ // navigation) or after an abort left the JSONL in an indeterminate state.
237
+ // REBUILD wipes and rewrites the file to match pi's current history.
238
+ needsRebuild?: boolean;
239
+ // Set ONLY after an abort. The killed CC subprocess may still be flushing
240
+ // a late "[Request interrupted by user]" record to the session JSONL.
241
+ // Reusing the same sessionId/path would race that orphan write into our
242
+ // fresh file and break CC's parent-uuid chain on the next resume. When
243
+ // this flag is set, REBUILD takes a fresh UUID and skips deleteSession
244
+ // so the orphan writes land on a dead inode. Compact/tree do NOT set
245
+ // this — there's no concurrent CC writer during those events, so
246
+ // in-place rebuild (preserve UUID, deleteSession + createSession) is safe.
247
+ forceRotate?: boolean;
248
+ }
249
+
250
+ let sharedSession: SessionState | null = null;
251
+
252
+ // Convert pi messages to Anthropic API format for session import.
253
+ // Lossy: non-Anthropic thinking blocks are dropped (no valid signature), and only
254
+ // text/image/toolCall block types are handled. If all blocks in an assistant message
255
+ // are filtered, the message is dropped — which can create invalid sequences (e.g.
256
+ // two user messages in a row, or tool_result without preceding tool_use).
257
+ function convertAndImportMessages(
258
+ session: ReturnType<typeof createSession>,
259
+ messages: Context["messages"],
260
+ customToolNameToSdk?: Map<string, string>,
261
+ ): void {
262
+ const { anthropicMessages, sanitizedIds } = convertPiMessages(messages, customToolNameToSdk);
263
+
264
+ debug(`convertAndImportMessages: ${messages.length} pi msgs → ${anthropicMessages.length} anthropic msgs`);
265
+ debug(`convertAndImportMessages: imported roles:`, anthropicMessages.map((m, i) => {
266
+ const c = m.content;
267
+ if (typeof c === "string") return `[${i}]${m.role}:text`;
268
+ if (Array.isArray(c)) return `[${i}]${m.role}:${c.map((b: { type?: string }) => b.type).join("+")}`;
269
+ return `[${i}]${m.role}:?`;
270
+ }).join(" "));
271
+ if (sanitizedIds.size > 0) {
272
+ debug(`convertAndImportMessages: sanitized ${sanitizedIds.size} tool IDs:`,
273
+ [...sanitizedIds.entries()].map(([orig, clean]) => orig === clean ? orig : `${orig}→${clean}`).join(", "));
274
+ }
275
+ if (messages.length) session.importPiMessages(messages);
276
+ }
277
+
278
+ // Pi doesn't pass tool results directly — it appends them to the context and calls
279
+ // the provider again. Thin wrapper over extract-tool-results.js that adds per-turn
280
+ // debug logging at the extraction boundary.
281
+ function extractAllToolResults(context: Context): McpResult[] {
282
+ const { results, stopIdx } = _extractAllToolResults(context.messages as unknown as Array<{ role: string; [key: string]: unknown }>);
283
+ debug(`extractAllToolResults: ${results.length} results from ${context.messages.length} msgs, stopped at index ${stopIdx}`);
284
+ debug(`extractAllToolResults: all msg roles:`, context.messages.map((m, i) => `[${i}]${m.role}`).join(" "));
285
+ for (let r = 0; r < results.length; r++) {
286
+ debug(`extractAllToolResults: result[${r}] id=${results[r].toolCallId}${results[r].isError ? " ERROR" : ""} contentLen=${JSON.stringify(results[r].content).length}`);
287
+ }
288
+ return results;
289
+ }
290
+
291
+ /** Extract the last user message from context as a prompt string. Returns null if last message is not a user message. */
292
+ function extractUserPrompt(messages: Context["messages"]): string | null {
293
+ const last = messages[messages.length - 1];
294
+ if (!last || last.role !== "user") return null;
295
+ if (typeof last.content === "string") return last.content;
296
+ return messageContentToText(last.content) || "";
297
+ }
298
+
299
+ /** Extract the last user message as ContentBlockParam[] (preserving images).
300
+ * Returns null if no images — caller should fall back to string prompt. */
301
+ function extractUserPromptBlocks(messages: Context["messages"]): ContentBlockParam[] | null {
302
+ const last = messages[messages.length - 1];
303
+ if (!last || last.role !== "user") return null;
304
+ if (typeof last.content === "string") {
305
+ debug(`extractUserPromptBlocks: content is string (length=${last.content.length})`);
306
+ return null;
307
+ }
308
+ if (!Array.isArray(last.content)) {
309
+ debug(`extractUserPromptBlocks: content is ${typeof last.content}`);
310
+ return null;
311
+ }
312
+ debug(`extractUserPromptBlocks: ${last.content.length} blocks, types=${last.content.map((b: any) => b.type).join(",")}`);
313
+ let hasImage = false;
314
+ const blocks: ContentBlockParam[] = [];
315
+ for (const block of last.content) {
316
+ if (block.type === "text" && block.text) {
317
+ blocks.push({ type: "text", text: block.text });
318
+ } else if (block.type === "image") {
319
+ debug(`image block: mimeType=${(block as any).mimeType}, data length=${((block as any).data ?? "").length}, keys=${Object.keys(block).join(",")}`);
320
+ if (!(block as any).data || !(block as any).mimeType) {
321
+ debug(`image block missing data or mimeType, skipping`);
322
+ continue;
323
+ }
324
+ hasImage = true;
325
+ blocks.push({
326
+ type: "image",
327
+ source: { type: "base64", media_type: block.mimeType, data: block.data },
328
+ });
329
+ }
330
+ }
331
+ return hasImage ? blocks : null;
332
+ }
333
+
334
+ async function* wrapPromptStream(blocks: ContentBlockParam[]): AsyncIterable<CbUserMessage> {
335
+ yield {
336
+ type: "user",
337
+ session_id: "",
338
+ parent_tool_use_id: null,
339
+ message: { role: "user", content: blocks as any },
340
+ };
341
+ }
342
+
343
+ function newAssistantOutput(model: Model<any>, text: string, stopReason: AssistantMessage["stopReason"], errorMessage?: string): AssistantMessage {
344
+ return {
345
+ role: "assistant",
346
+ content: text ? [{ type: "text", text }] : [],
347
+ api: model.api,
348
+ provider: model.provider,
349
+ model: model.id,
350
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
351
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
352
+ stopReason,
353
+ ...(errorMessage ? { errorMessage } : {}),
354
+ timestamp: Date.now(),
355
+ };
356
+ }
357
+
358
+ function extractIsolatedSummaryPrompt(messages: Context["messages"]): string {
359
+ if (messages.length !== 1 || messages[0].role !== "user") {
360
+ throw new Error(
361
+ `isolatedStreamFn: expected exactly 1 user message, got ${messages.length} ` +
362
+ `(${messages.map((m) => m.role).join(",")})`,
363
+ );
364
+ }
365
+ const promptText = extractUserPrompt(messages);
366
+ if (!promptText) throw new Error("isolatedStreamFn: summarization prompt is empty");
367
+ return promptText;
368
+ }
369
+
370
+ function resultErrorText(message: CbMessage): string {
371
+ const result = message as CbMessage & { subtype?: string; errors?: unknown; error?: unknown };
372
+ if (Array.isArray(result.errors)) return result.errors.map(String).join("\n");
373
+ if (typeof result.error === "string") return result.error;
374
+ return `CodeBuddy summary failed: ${result.subtype ?? "unknown result"}`;
375
+ }
376
+
377
+ function isolatedStreamFn(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
378
+ const stream = newAssistantMessageEventStream();
379
+ void runIsolatedSummary(model, context, options, stream);
380
+ return stream;
381
+ }
382
+
383
+ async function runIsolatedSummary(
384
+ model: Model<any>,
385
+ context: Context,
386
+ options: SimpleStreamOptions | undefined,
387
+ stream: AssistantMessageEventStream,
388
+ ): Promise<void> {
389
+ let sdkQuery: ReturnType<typeof query> | undefined;
390
+ let wasAborted = false;
391
+ const onAbort = () => {
392
+ wasAborted = true;
393
+ void sdkQuery?.interrupt().catch(() => {});
394
+ try { sdkQuery?.interrupt(); } catch {}
395
+ };
396
+
397
+ try {
398
+ const promptText = extractIsolatedSummaryPrompt(context.messages);
399
+ const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
400
+ const codebuddyExecutable = loadConfig(cwd).provider?.pathToCodebuddyCode;
401
+ const cliModel = codebuddyModelId(model);
402
+ debug(`compact summary: spawn model=${cliModel} registeredModel=${model.id} promptLen=${promptText.length}`);
403
+
404
+ sdkQuery = query({
405
+ prompt: promptText,
406
+ options: {
407
+ cwd,
408
+ env: { ...process.env, DISABLE_AUTO_COMPACT: "1" },
409
+ tools: [],
410
+ strictMcpConfig: true,
411
+ settingSources: [] as SettingSource[],
412
+ persistSession: false,
413
+ systemPrompt: context.systemPrompt,
414
+ model: cliModel,
415
+ maxTurns: 1,
416
+ ...(codebuddyExecutable ? { pathToCodebuddyCode: codebuddyExecutable } : {}),
417
+ ...makeCliDebugOptions("compact-summary"),
418
+ },
419
+ });
420
+
421
+ if (options?.signal) {
422
+ if (options.signal.aborted) onAbort();
423
+ else options.signal.addEventListener("abort", onAbort, { once: true });
424
+ }
425
+
426
+ let assistantText = "";
427
+ let finalText = "";
428
+ let errorText: string | undefined;
429
+ let firstEventLogged = false;
430
+
431
+ for await (const message of sdkQuery) {
432
+ if (!firstEventLogged) {
433
+ debug(`compact summary: first event type=${message.type}`);
434
+ firstEventLogged = true;
435
+ }
436
+ if (wasAborted) break;
437
+
438
+ if (message.type === "assistant") {
439
+ for (const block of (message as any).message?.content ?? []) {
440
+ if (block.type === "text" && typeof block.text === "string") assistantText += block.text;
441
+ }
442
+ } else if (message.type === "result") {
443
+ logServedContextWindow("compact summary", message, model);
444
+ if (message.subtype === "success") {
445
+ finalText = message.result || assistantText;
446
+ } else {
447
+ errorText = resultErrorText(message);
448
+ }
449
+ }
450
+ }
451
+
452
+ if (wasAborted) {
453
+ const output = newAssistantOutput(model, "", "aborted", "Operation aborted");
454
+ debug("compact summary: aborted");
455
+ stream.push({ type: "error", reason: "aborted", error: output });
456
+ stream.end();
457
+ return;
458
+ }
459
+
460
+ const text = finalText || assistantText;
461
+ if (errorText || !text.trim()) {
462
+ const msg = errorText ?? "CodeBuddy summary returned empty text";
463
+ debug(`compact summary: error ${msg}`);
464
+ stream.push({ type: "error", reason: "error", error: newAssistantOutput(model, "", "error", msg) });
465
+ stream.end();
466
+ return;
467
+ }
468
+
469
+ debug(`compact summary: done textLen=${text.length}`);
470
+ stream.push({ type: "done", reason: "stop", message: newAssistantOutput(model, text, "stop") });
471
+ stream.end();
472
+ } catch (err) {
473
+ const msg = errorMessage(err);
474
+ debug("runIsolatedSummary threw; pushing terminal error", err);
475
+ stream.push({ type: "error", reason: "error", error: newAssistantOutput(model, "", "error", msg) });
476
+ stream.end();
477
+ } finally {
478
+ options?.signal?.removeEventListener("abort", onAbort);
479
+ try { sdkQuery?.interrupt(); } catch {}
480
+ }
481
+ }
482
+
483
+ function reinjectPriorCompactionFileOps(branchEntries: Array<{ type: string; details?: unknown }>, preparation: { fileOps: { read: Set<string>; edited: Set<string> } }): void {
484
+ const prior = [...branchEntries]
485
+ .reverse()
486
+ .find((entry): entry is CompactionEntry => entry.type === "compaction");
487
+ const details = prior?.details as { readFiles?: unknown; modifiedFiles?: unknown } | undefined;
488
+ if (!Array.isArray(details?.readFiles) || !Array.isArray(details?.modifiedFiles)) return;
489
+ for (const file of details.readFiles) preparation.fileOps.read.add(String(file));
490
+ for (const file of details.modifiedFiles) preparation.fileOps.edited.add(String(file));
491
+ debug(`compact takeover: re-injected prior file ops read=${details.readFiles.length} modified=${details.modifiedFiles.length}`);
492
+ }
493
+
494
+ interface SyncResult {
495
+ sessionId: string | null;
496
+ preserveSharedSession?: boolean;
497
+ }
498
+
499
+ /**
500
+ * Ensure the shared session has all messages up to (but not including) the last user message.
501
+ * Returns session ID to resume from, or null if no resume needed.
502
+ */
503
+ // Read the session file we just wrote and sanity-check it. Warns instead of
504
+ // throwing — CC may be more tolerant than our checks, so a false positive
505
+ // shouldn't block the user. Pure logic is in session-verify.js; this wrapper
506
+ // fans each warning out to debug log + piUI notify + diagDump.
507
+ function verifyWrittenSession(
508
+ jsonlPath: string,
509
+ expectedSessionId: string,
510
+ expectedRecordCount: number,
511
+ cwd: string,
512
+ ): void {
513
+ const warnings = _verifyWrittenSession(jsonlPath, expectedSessionId, expectedRecordCount);
514
+ for (const msg of warnings) {
515
+ debug(`WARNING session verify: ${msg}`);
516
+ piUI?.notify(
517
+ `Session sync issue: ${msg}. ` +
518
+ `cwd=${redactForLog(cwd)}` +
519
+ (DEBUG ? ` (see ${redactForLog(DEBUG_LOG_PATH)})` : ` (set CODEBUDDY_SDK_DEBUG=1 for a local log)`) +
520
+ `. Report: ${ISSUES_URL}`,
521
+ "warning",
522
+ );
523
+ diagDump("session_verify_fail", { msg, jsonlPath, cwd, realpath: safeRealpath(cwd), codebuddyConfigDir: process.env.CODEBUDDY_CONFIG_DIR ?? null });
524
+ }
525
+ }
526
+
527
+ function safeRealpath(p: string): string {
528
+ try { return realpathSync(p); } catch (e) { return `<failed: ${(e as Error).message}>`; }
529
+ }
530
+
531
+ // Diagnostic snapshot of where a session file was just written. Catches the
532
+ // class of bugs where pi writes to ~/.claude/projects/<X> but CC SDK reads
533
+ // from ~/.claude/projects/<Y> (symlinks, CODEBUDDY_CONFIG_DIR, hash mismatch).
534
+ function debugSessionPaths(label: string, cwd: string, jsonlPath: string): void {
535
+ const realCwd = safeRealpath(cwd);
536
+ let fileSize: number | null = null;
537
+ let fileExists = false;
538
+ try {
539
+ const st = statSync(jsonlPath);
540
+ fileExists = true;
541
+ fileSize = st.size;
542
+ } catch { /* file may not exist yet */ }
543
+ debug(`${label}: cwd=${redactForLog(cwd)}`);
544
+ if (realCwd !== cwd) debug(`${label}: realpath(cwd)=${redactForLog(realCwd)} (symlink-resolved)`);
545
+ debug(`${label}: jsonlPath=${redactForLog(jsonlPath)}`);
546
+ debug(`${label}: fileExists=${fileExists}${fileSize != null ? ` size=${fileSize}` : ""}`);
547
+ }
548
+
549
+ // Two semantic paths:
550
+ // REUSE — pi's history is in sync with the existing sharedSession (or drifted
551
+ // only by the trailing final-assistant message that pi appends after
552
+ // streamSimple returns, which CC's own persisted session already has).
553
+ // Returns the existing sessionId. Keeps CC's prompt cache warm.
554
+ // REBUILD — no session yet, or pi's history has diverged (non-trailing
555
+ // missed messages, e.g. another provider took a turn). Wipes the existing
556
+ // session file (if any) and writes a fresh one containing all prior
557
+ // messages, reusing the same sessionId across rebuilds so UUIDs stay
558
+ // stable for the lifetime of pi's session.
559
+ //
560
+ // Why a full rebuild rather than patching:
561
+ // Injecting deltas into an existing session creates a branch that CC's
562
+ // --resume doesn't follow (documented attempt prior to this). A complete
563
+ // overwrite at the same path is simpler and correct.
564
+ //
565
+ // Why reuse the sessionId across rebuilds:
566
+ // CC re-reads the JSONL on every --resume call — no in-process UUID
567
+ // caching. Validated in tests/exp-session-clear.mjs, including the case
568
+ // where CC had appended its own tool_use/tool_result records between
569
+ // rebuilds. Preserving the UUID means stable log correlation across
570
+ // provider switches and no orphaned session files.
571
+ //
572
+ // Log strings still say "Case 1/2/3/4" so existing diagnostics (int-cache.sh,
573
+ // int-session-resume.mjs) keep grepping the same anchors.
574
+ function syncSharedSession(
575
+ messages: Context["messages"],
576
+ cwd: string,
577
+ customToolNameToSdk?: Map<string, string>,
578
+ modelId?: string,
579
+ ): SyncResult {
580
+ const priorMessages = messages.slice(0, -1); // everything before the new user prompt
581
+
582
+ // REUSE path
583
+ //
584
+ // Guard on priorMessages.length >= cursor: a shorter incoming context cannot
585
+ // be a continuation of the cached session. This is the general invariant for
586
+ // pi-side history rewrites such as /compact and session_tree: without it,
587
+ // missed = [].slice(cursor) can falsely hit REUSE and resume an unrelated
588
+ // longer CC session. See issue #25.
589
+ if (sharedSession && !sharedSession.needsRebuild && priorMessages.length >= sharedSession.cursor) {
590
+ const missed = priorMessages.slice(sharedSession.cursor);
591
+ const trailingAssistantOnly =
592
+ missed.length === 1 && (missed[0] as { role?: string }).role === "assistant";
593
+ if (missed.length === 0 || trailingAssistantOnly) {
594
+ if (trailingAssistantOnly) {
595
+ sharedSession = { ...sharedSession, cursor: priorMessages.length, cwd };
596
+ }
597
+ debug(`Case 3: ${trailingAssistantOnly ? "advanced cursor past trailing assistant, " : ""}resuming session ${sharedSession.sessionId.slice(0, 8)}, cursor=${sharedSession.cursor}`);
598
+ debug(`syncResult: path=reuse sessionId=${sharedSession.sessionId} cursor=${sharedSession.cursor}`);
599
+ return { sessionId: sharedSession.sessionId };
600
+ }
601
+ }
602
+ // Only reachable when needsRebuild is false — user-facing history rewrites
603
+ // (/compact, session_tree, /new, fork) always set needsRebuild or clear
604
+ // sharedSession before the next syncSharedSession call. In practice this
605
+ // fires only for isolated compact-summary subprocesses.
606
+ if (sharedSession && !sharedSession.needsRebuild && priorMessages.length < sharedSession.cursor) {
607
+ debug(`Case 1 synthetic: clean start for shorter context, preserving shared session ${sharedSession.sessionId.slice(0, 8)}, cursor=${sharedSession.cursor}`);
608
+ debug(`syncResult: path=clean-start preserve-shared sessionId=${sharedSession.sessionId} cursor=${sharedSession.cursor}`);
609
+ return { sessionId: null, preserveSharedSession: true };
610
+ }
611
+
612
+ // REBUILD path
613
+ if (priorMessages.length === 0) {
614
+ debug(`Case 1: clean start, ${messages.length} total messages`);
615
+ debug(`syncResult: path=clean-start`);
616
+ return { sessionId: null };
617
+ }
618
+ const previousSessionId = sharedSession?.sessionId;
619
+ const previousCursor = sharedSession?.cursor ?? 0;
620
+ // preserveId: rebuild in place (deleteSession + createSession with the
621
+ // existing UUID), so prompt-cache UUIDs stay stable for log correlation
622
+ // and for any tools that key off them. Skipped only when there's a
623
+ // concurrent writer we shouldn't race — see forceRotate docs above.
624
+ const preserveId = previousSessionId !== undefined && !sharedSession?.forceRotate;
625
+ if (preserveId) {
626
+ // Wipe prior jsonl + companion dir (no-op if nothing to wipe).
627
+ deleteSession(previousSessionId!, cwd, process.env.CODEBUDDY_CONFIG_DIR);
628
+ }
629
+ const session = createSession({
630
+ projectPath: cwd,
631
+ codebuddyDir: process.env.CODEBUDDY_CONFIG_DIR,
632
+ ...(preserveId ? { sessionId: previousSessionId } : {}),
633
+ ...(modelId ? { model: modelId } : {}),
634
+ });
635
+ convertAndImportMessages(session, priorMessages, customToolNameToSdk);
636
+ session.save();
637
+ verifyWrittenSession(session.jsonlPath, session.sessionId, session.messages.length, cwd);
638
+ sharedSession = { sessionId: session.sessionId, cursor: priorMessages.length, cwd };
639
+ if (previousSessionId === undefined) {
640
+ debug(`Case 2: first turn with ${priorMessages.length} prior messages → session ${session.sessionId.slice(0, 8)}, ${session.messages.length} records`);
641
+ } else if (preserveId) {
642
+ const missedCount = priorMessages.length - previousCursor;
643
+ debug(`Case 4: ${missedCount} missed messages, ${priorMessages.length} total → rewrote session ${session.sessionId.slice(0, 8)} (same id), ${session.messages.length} records`);
644
+ } else {
645
+ 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`);
646
+ }
647
+ debugSessionPaths(`${session.sessionId.slice(0, 8)}`, cwd, session.jsonlPath);
648
+ debug(`syncResult: path=rebuild sessionId=${session.sessionId} priors=${priorMessages.length} ${previousSessionId === undefined ? "first" : preserveId ? "preserved" : "rotated-post-abort"}`);
649
+ return { sessionId: session.sessionId };
650
+ }
651
+
652
+ // @internal
653
+ export const __test = {
654
+ resetSharedSession() {
655
+ sharedSession = null;
656
+ },
657
+ setSharedSession(state: SessionState | null) {
658
+ sharedSession = state;
659
+ },
660
+ getSharedSession() {
661
+ return sharedSession;
662
+ },
663
+ createDelegationSessionFromContext,
664
+ buildProviderBoundaryOptions,
665
+ buildProviderQueryOptions,
666
+ syncSharedSession,
667
+ };
668
+
669
+ function createDelegationSessionFromContext(
670
+ messages: Context["messages"] | undefined,
671
+ cwd: string,
672
+ modelId?: string,
673
+ ): string | null {
674
+ if (!messages?.length) return null;
675
+ const delegationMessages = stripToolHistoryForDelegation(messages);
676
+ if (!delegationMessages.length) return null;
677
+ const session = createSession({
678
+ projectPath: cwd,
679
+ codebuddyDir: process.env.CODEBUDDY_CONFIG_DIR,
680
+ ...(modelId ? { model: modelId } : {}),
681
+ });
682
+ convertAndImportMessages(session, delegationMessages);
683
+ session.save();
684
+ verifyWrittenSession(session.jsonlPath, session.sessionId, session.messages.length, cwd);
685
+ debug(`askCodebuddy: created delegation session ${session.sessionId.slice(0, 8)} records=${session.messages.length}`);
686
+ return session.sessionId;
687
+ }
688
+
689
+ function stripToolHistoryForDelegation(messages: Context["messages"]): Context["messages"] {
690
+ return messages.flatMap((message) => {
691
+ if (message.role === "toolResult") return [];
692
+ if (message.role !== "assistant" || !Array.isArray(message.content)) return [message];
693
+ const content = message.content.filter((block) => block.type !== "toolCall");
694
+ return content.length ? [{ ...message, content }] : [];
695
+ }) as Context["messages"];
696
+ }
697
+
698
+ function buildProviderBoundaryOptions(settings: NonNullable<Config["provider"]>) {
699
+ const appendSystemPrompt = settings.appendSystemPrompt !== false;
700
+ const strictMcpConfigEnabled = settings.strictMcpConfig !== false;
701
+ const extraArgs: Record<string, string | null> = {};
702
+ if (strictMcpConfigEnabled) extraArgs["strict-mcp-config"] = null;
703
+ return {
704
+ appendSystemPrompt,
705
+ strictMcpConfigEnabled,
706
+ tools: [] as string[],
707
+ extraArgs,
708
+ settingSources: appendSystemPrompt
709
+ ? undefined
710
+ : settings.settingSources ?? ["user", "project"] as SettingSource[],
711
+ };
712
+ }
713
+
714
+ function buildProviderQueryOptions(input: {
715
+ providerSettings: NonNullable<Config["provider"]>;
716
+ cliModel: string;
717
+ cwd: string;
718
+ env: NodeJS.ProcessEnv;
719
+ systemPrompt?: string;
720
+ effort?: Effort;
721
+ mcpServers?: SdkQueryOptions["mcpServers"];
722
+ resumeSessionId?: string | null;
723
+ codebuddyExecutable?: string;
724
+ debugOptions?: CliDebugOptions;
725
+ }): SdkQueryOptions {
726
+ const boundaryOptions = buildProviderBoundaryOptions(input.providerSettings);
727
+ return {
728
+ cwd: input.cwd,
729
+ env: input.env,
730
+ tools: boundaryOptions.tools,
731
+ permissionMode: "bypassPermissions",
732
+ includePartialMessages: true,
733
+ systemPrompt: input.systemPrompt,
734
+ extraArgs: { ...boundaryOptions.extraArgs, model: input.cliModel },
735
+ ...(input.effort ? { effort: input.effort } : {}),
736
+ ...(boundaryOptions.settingSources ? { settingSources: boundaryOptions.settingSources } : {}),
737
+ ...(input.mcpServers ? { mcpServers: input.mcpServers } : {}),
738
+ ...(input.resumeSessionId ? { resume: input.resumeSessionId } : {}),
739
+ ...(input.codebuddyExecutable ? { pathToCodebuddyCode: input.codebuddyExecutable } : {}),
740
+ ...(input.debugOptions ?? {}),
741
+ };
742
+ }
743
+
744
+ // --- Provider helpers: tool name mapping ---
745
+
746
+ function mapToolName(name: string, customToolNameToPi?: Map<string, string>): string {
747
+ const normalized = name.toLowerCase();
748
+ const builtin = SDK_TO_PI_TOOL_NAME[normalized];
749
+ if (builtin) return builtin;
750
+ if (customToolNameToPi) {
751
+ const mapped = customToolNameToPi.get(name) ?? customToolNameToPi.get(normalized);
752
+ if (mapped) return mapped;
753
+ }
754
+ if (normalized.startsWith(MCP_TOOL_PREFIX)) return name.slice(MCP_TOOL_PREFIX.length);
755
+ return name;
756
+ }
757
+
758
+ // Renames for CodeBuddy SDK param names that differ from pi's native names.
759
+ // Keys not listed here pass through unchanged, so new pi params work automatically.
760
+ const SDK_KEY_RENAMES: Record<string, Record<string, string>> = {
761
+ read: { file_path: "path" },
762
+ write: { file_path: "path" },
763
+ edit: { file_path: "path", old_string: "oldText", new_string: "newText", old_text: "oldText", new_text: "newText" },
764
+ };
765
+
766
+ // Maps SDK tool args to pi tool args via key renaming + pass-through.
767
+ // Pi's own prepareArguments hooks handle any structural transforms (e.g. edit oldText/newText → edits[]).
768
+ function mapToolArgs(
769
+ toolName: string, args: Record<string, unknown> | undefined,
770
+ ): Record<string, unknown> {
771
+ const input = args ?? {};
772
+ const renames = SDK_KEY_RENAMES[toolName.toLowerCase()];
773
+ const result: Record<string, unknown> = {};
774
+ for (const [key, value] of Object.entries(input)) {
775
+ const piKey = renames?.[key] ?? key;
776
+ if (!(piKey in result)) result[piKey] = value; // first alias wins
777
+ }
778
+ // Pi bash has no default timeout; add a safety default
779
+ if (toolName.toLowerCase() === "bash" && result.timeout == null) {
780
+ result.timeout = 120;
781
+ }
782
+ return result;
783
+ }
784
+
785
+ // --- Provider helpers: tool resolution ---
786
+
787
+ // --- Provider helpers: tool bridge ---
788
+
789
+ // --- Query state ---
790
+ // QueryContext lives in query-state.js so tests can import it without
791
+ // activating the extension.
792
+
793
+ // Global (not query state):
794
+ let piUI: ExtensionUIContext | null = null;
795
+ const activeQueryContexts = new Set<QueryContext>();
796
+
797
+ function contextForToolResults(results: McpResult[]): QueryContext | undefined {
798
+ for (const result of results) {
799
+ const id = result.toolCallId;
800
+ if (!id) continue;
801
+ for (const queryCtx of activeQueryContexts) {
802
+ if (queryCtx.pendingToolCalls.has(id) || queryCtx.pendingResults.has(id) || queryCtx.turnToolCallIds.includes(id)) {
803
+ return queryCtx;
804
+ }
805
+ }
806
+ }
807
+ return undefined;
808
+ }
809
+
810
+ function resolveMcpTools(context: Context, excludeToolName?: string): {
811
+ mcpTools: Tool[];
812
+ customToolNameToSdk: Map<string, string>;
813
+ customToolNameToPi: Map<string, string>;
814
+ } {
815
+ const mcpTools: Tool[] = [];
816
+ const customToolNameToSdk = new Map<string, string>();
817
+ const customToolNameToPi = new Map<string, string>();
818
+
819
+ if (!context.tools) return { mcpTools, customToolNameToSdk, customToolNameToPi };
820
+
821
+ for (const tool of context.tools) {
822
+ if (tool.name === excludeToolName) continue;
823
+ const sdkName = `${MCP_TOOL_PREFIX}${tool.name}`;
824
+ mcpTools.push(enhancePiToolForCodebuddy(tool));
825
+ customToolNameToSdk.set(tool.name, sdkName);
826
+ customToolNameToSdk.set(tool.name.toLowerCase(), sdkName);
827
+ customToolNameToPi.set(sdkName, tool.name);
828
+ customToolNameToPi.set(sdkName.toLowerCase(), tool.name);
829
+ }
830
+
831
+ return { mcpTools, customToolNameToSdk, customToolNameToPi };
832
+ }
833
+
834
+ // Creates an MCP server that bridges pi tools to the SDK. Each tool handler
835
+ // blocks on a Promise until pi delivers the tool result via streamSimple.
836
+ // Handlers are assigned toolCallIds from turnToolCallIds (populated when the SDK
837
+ // emits tool_use blocks). Results are matched by ID, not position.
838
+ // Handlers close over the captured `queryCtx`, ensuring they operate on the
839
+ // correct query's state while multiple queries run concurrently.
840
+ function buildMcpServers(tools: Tool[], queryCtx: QueryContext): Record<string, ReturnType<typeof createSdkMcpServer>> | undefined {
841
+ if (!tools.length) return undefined;
842
+ const mcpTools = tools.map((tool) => ({
843
+ name: tool.name,
844
+ description: tool.description,
845
+ inputSchema: jsonSchemaToZodObject(tool.parameters),
846
+ handler: async () => {
847
+ const toolCallId = queryCtx.turnToolCallIds[queryCtx.nextHandlerIdx++];
848
+ if (!toolCallId) debug(`WARNING: mcp handler ${tool.name} has no toolCallId (idx=${queryCtx.nextHandlerIdx - 1}, available=${queryCtx.turnToolCallIds.length})`);
849
+ if (toolCallId && queryCtx.pendingResults.has(toolCallId)) {
850
+ const result = queryCtx.pendingResults.get(toolCallId)!;
851
+ queryCtx.pendingResults.delete(toolCallId);
852
+ debug(`mcp handler: ${tool.name} [${toolCallId}] → resolved from queue (${queryCtx.pendingResults.size} remaining)`);
853
+ return result;
854
+ }
855
+ debug(`mcp handler: ${tool.name} [${toolCallId}] → waiting`);
856
+ return new Promise<McpResult>((resolve) => {
857
+ queryCtx.pendingToolCalls.set(toolCallId, { toolName: tool.name, resolve });
858
+ });
859
+ },
860
+ }));
861
+ const server = createSdkMcpServer({ name: MCP_SERVER_NAME, version: "1.0.0", tools: mcpTools });
862
+ return { [MCP_SERVER_NAME]: server };
863
+ }
864
+
865
+ // --- Usage helpers ---
866
+
867
+ function updateUsage(output: AssistantMessage, usage: Record<string, number | undefined>, model: Model<any>): void {
868
+ if (usage.input_tokens != null) output.usage.input = usage.input_tokens;
869
+ if (usage.output_tokens != null) output.usage.output = usage.output_tokens;
870
+ if (usage.cache_read_input_tokens != null) output.usage.cacheRead = usage.cache_read_input_tokens;
871
+ if (usage.cache_creation_input_tokens != null) output.usage.cacheWrite = usage.cache_creation_input_tokens;
872
+ // CodeBuddy may report reasoning/thinking tokens separately, while pi's Usage type does not model that field.
873
+ const reasoning = usage.reasoning_tokens ?? usage.thinking_tokens;
874
+ if (reasoning != null) (output.usage as typeof output.usage & { reasoning?: number }).reasoning = reasoning;
875
+ output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
876
+ calculateCost(model, output.usage);
877
+ const promptTokens = output.usage.input + output.usage.cacheRead + output.usage.cacheWrite;
878
+ const cachePct = promptTokens > 0 ? Math.round(output.usage.cacheRead / promptTokens * 100) : 0;
879
+ const reasoningText = reasoning != null ? ` reasoning=${reasoning}` : "";
880
+ debug(`usage: in=${output.usage.input} out=${output.usage.output} cacheRead=${output.usage.cacheRead} cacheWrite=${output.usage.cacheWrite} total=${output.usage.totalTokens}${reasoningText} cachePct=${cachePct}% model=${model.id}`);
881
+ }
882
+
883
+ // Log the *served* context window reported by an SDK result message
884
+ // (modelUsage[id].contextWindow), which can differ from the window pi
885
+ // registered (model.contextWindow) when the runtime entitlement doesn't
886
+ // match the docs — e.g. bare Opus served 200K on Pro, or [1m] not honored.
887
+ // The result message's modelUsage is otherwise discarded; this makes the
888
+ // gap observable. See issue #18.
889
+ function logServedContextWindow(label: string, message: CbMessage, model: Model<any>): void {
890
+ const modelUsage = (message as any).modelUsage as Record<string, { contextWindow?: number; maxOutputTokens?: number }> | undefined;
891
+ if (!modelUsage) return;
892
+ for (const [k, v] of Object.entries(modelUsage)) {
893
+ debug(`${label}: served contextWindow=${v.contextWindow ?? "?"} maxOutputTokens=${v.maxOutputTokens ?? "?"} servedModel=${k} registered=${model.contextWindow}`);
894
+ if (typeof v.contextWindow === "number") observeServedContextWindow(label, k, v.contextWindow, model);
895
+ }
896
+ }
897
+
898
+ function observeServedContextWindow(label: string, servedModel: string, observed: number, model: Model<any>): void {
899
+ if (!Number.isFinite(observed) || observed <= 0) return;
900
+ try {
901
+ const previousRegistered = MODELS.find((candidate) => candidate.id === model.id)?.contextWindow;
902
+ const { changed, floorChanged, record } = recordObservedContextWindow(
903
+ calibrationCache,
904
+ model.id,
905
+ calibrationEnvironment,
906
+ observed,
907
+ );
908
+ if (!changed) return;
909
+ saveCalibrationCache(calibrationCache);
910
+ const floor = record.capabilities.contextWindow?.floor;
911
+ if (floor != null) {
912
+ MODELS = MODELS.map((candidate) => (
913
+ candidate.id === model.id
914
+ ? { ...candidate, contextWindow: floor }
915
+ : candidate
916
+ ));
917
+ }
918
+ debug(
919
+ `calibration: ${label} observed=${observed} floor=${record.capabilities.contextWindow?.floor ?? "?"} ` +
920
+ `latest=${record.capabilities.contextWindow?.latest ?? "?"} servedModel=${servedModel} registeredBefore=${previousRegistered ?? "?"}`,
921
+ );
922
+ if (floorChanged && floor != null && previousRegistered != null && floor !== previousRegistered) {
923
+ scheduleCalibrationRefresh(`contextWindow:${model.id}`);
924
+ }
925
+ } catch (err) {
926
+ debug(`calibration: failed to persist observed context window for ${model.id}`, err);
927
+ }
928
+ }
929
+
930
+ // --- Effort level mapping ---
931
+ // Pi reasoning levels → CC SDK effort levels
932
+
933
+ const REASONING_TO_EFFORT: Record<string, Effort> = {
934
+ minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "xhigh",
935
+ };
936
+
937
+ // --- Provider helpers: misc ---
938
+
939
+ function mapStopReason(reason: string | undefined): "stop" | "length" | "toolUse" {
940
+ switch (reason) {
941
+ case "tool_use": return "toolUse";
942
+ case "max_tokens": return "length";
943
+ case "end_turn": default: return "stop";
944
+ }
945
+ }
946
+
947
+ function parsePartialJson(input: string, fallback: Record<string, unknown>): Record<string, unknown> {
948
+ if (!input) return fallback;
949
+ try { return JSON.parse(input); } catch { return fallback; }
950
+ }
951
+
952
+
953
+ // --- Provider: streaming function ---
954
+ //
955
+ // Push-based streaming with MCP tool bridge:
956
+ // 1. streamSimple starts a query() and kicks off consumeQuery() in background
957
+ // 2. consumeQuery() iterates the SDK generator, pushing events to currentPiStream
958
+ // 3. On tool_use: ends the current pi stream, nulls it out. The MCP handler
959
+ // blocks the generator naturally — no events arrive until resolved.
960
+ // 4. Pi executes the tool, calls streamSimple again. We swap in the new stream,
961
+ // resolve the MCP handler, and the generator unblocks — events flow to new stream.
962
+ //
963
+ // Note: resetTurnState clears turnSawStreamEvent while the generator may still
964
+ // have queued messages from the previous turn. This is safe because step 3 nulls
965
+ // currentPiStream, so any leftover messages hit the `!ctx().currentPiStream` guard
966
+ // in consumeQuery and are skipped before resetTurnState runs.
967
+
968
+ const completedStreams = new WeakSet<object>();
969
+
970
+ function markStreamComplete(stream: AssistantMessageEventStream | null): void {
971
+ if (stream) completedStreams.add(stream as object);
972
+ }
973
+
974
+ function claimCurrentPiStream(stream: AssistantMessageEventStream, label: string, c: QueryContext): void {
975
+ if (c.currentPiStream && !completedStreams.has(c.currentPiStream as object)) {
976
+ debug(`WARNING: currentPiStream overwritten before terminal event (${label}); activeQuery=${Boolean(c.activeQuery)} pendingHandlers=${c.pendingToolCalls.size}`);
977
+ }
978
+ c.currentPiStream = stream;
979
+ }
980
+
981
+ function ensureTurnStarted(c: QueryContext): void {
982
+ if (!c.turnStarted && c.currentPiStream && c.turnOutput) {
983
+ c.currentPiStream!.push({ type: "start", partial: c.turnOutput });
984
+ c.turnStarted = true;
985
+ }
986
+ }
987
+
988
+ function finalizeCurrentStream(c: QueryContext, stopReason?: string): void {
989
+ if (!c.currentPiStream || !c.turnOutput) return;
990
+ debug(`provider: finalizeCurrentStream called, stopReason=${stopReason}, outputStop=${c.turnOutput!.stopReason}`);
991
+ if (!c.turnStarted) ensureTurnStarted(c);
992
+ const reason = stopReason === "length" ? "length" : "stop";
993
+ const stream = c.currentPiStream;
994
+ stream!.push({ type: "done", reason, message: c.turnOutput });
995
+ markStreamComplete(stream);
996
+ stream!.end();
997
+ c.currentPiStream = null;
998
+ }
999
+
1000
+ /** Maps Anthropic stream events to pi stream events (text, thinking, toolcall).
1001
+ * On message_stop with tool_use: ends currentPiStream so pi can execute the tool. */
1002
+ function processStreamEvent(
1003
+ message: CbMessage,
1004
+ customToolNameToPi: Map<string, string>,
1005
+ model: Model<any>,
1006
+ c: QueryContext,
1007
+ ): void {
1008
+ if (!c.currentPiStream || !c.turnOutput) return;
1009
+ c.turnSawStreamEvent = true;
1010
+ const event = (message as CbMessage & { event: any }).event;
1011
+
1012
+ if (event?.type === "message_start") {
1013
+ c.turnToolCallIds = [];
1014
+ c.nextHandlerIdx = 0;
1015
+ if (event.message?.usage) updateUsage(c.turnOutput, event.message.usage, model);
1016
+ return;
1017
+ }
1018
+
1019
+ if (event?.type === "content_block_start") {
1020
+ ensureTurnStarted(c);
1021
+ if (event.content_block?.type === "text") {
1022
+ c.turnBlocks.push({ type: "text", text: "", index: event.index });
1023
+ c.currentPiStream!.push({ type: "text_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
1024
+ } else if (event.content_block?.type === "thinking") {
1025
+ c.turnBlocks.push({ type: "thinking", thinking: "", thinkingSignature: "", index: event.index });
1026
+ c.currentPiStream!.push({ type: "thinking_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
1027
+ } else if (event.content_block?.type === "tool_use") {
1028
+ c.turnSawToolCall = true;
1029
+ c.turnToolCallIds.push(event.content_block.id);
1030
+ c.turnBlocks.push({
1031
+ type: "toolCall", id: event.content_block.id,
1032
+ name: mapToolName(event.content_block.name, customToolNameToPi),
1033
+ arguments: (event.content_block.input as Record<string, unknown>) ?? {},
1034
+ partialJson: "", index: event.index,
1035
+ });
1036
+ c.currentPiStream!.push({ type: "toolcall_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
1037
+ } else {
1038
+ debug("processStreamEvent: unhandled content_block_start type", event.content_block?.type);
1039
+ }
1040
+ return;
1041
+ }
1042
+
1043
+ if (event?.type === "content_block_delta") {
1044
+ const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
1045
+ const block = c.turnBlocks[index];
1046
+ if (!block) return;
1047
+ if (event.delta?.type === "text_delta" && block.type === "text") {
1048
+ block.text += event.delta.text;
1049
+ c.currentPiStream!.push({ type: "text_delta", contentIndex: index, delta: event.delta.text, partial: c.turnOutput });
1050
+ } else if (event.delta?.type === "thinking_delta" && block.type === "thinking") {
1051
+ block.thinking += event.delta.thinking;
1052
+ c.currentPiStream!.push({ type: "thinking_delta", contentIndex: index, delta: event.delta.thinking, partial: c.turnOutput });
1053
+ } else if (event.delta?.type === "input_json_delta" && block.type === "toolCall") {
1054
+ block.partialJson += event.delta.partial_json;
1055
+ block.arguments = parsePartialJson(block.partialJson, block.arguments);
1056
+ c.currentPiStream!.push({ type: "toolcall_delta", contentIndex: index, delta: event.delta.partial_json, partial: c.turnOutput });
1057
+ } else if (event.delta?.type === "signature_delta" && block.type === "thinking") {
1058
+ block.thinkingSignature = (block.thinkingSignature ?? "") + event.delta.signature;
1059
+ } else {
1060
+ debug("processStreamEvent: unhandled content_block_delta type", event.delta?.type);
1061
+ }
1062
+ return;
1063
+ }
1064
+
1065
+ if (event?.type === "content_block_stop") {
1066
+ const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
1067
+ const block = c.turnBlocks[index];
1068
+ if (!block) return;
1069
+ delete block.index;
1070
+ if (block.type === "text") {
1071
+ c.currentPiStream!.push({ type: "text_end", contentIndex: index, content: block.text, partial: c.turnOutput });
1072
+ } else if (block.type === "thinking") {
1073
+ c.currentPiStream!.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: c.turnOutput });
1074
+ } else if (block.type === "toolCall") {
1075
+ c.turnSawToolCall = true;
1076
+ block.arguments = mapToolArgs(
1077
+ block.name, parsePartialJson(block.partialJson, block.arguments),
1078
+ );
1079
+ delete block.partialJson;
1080
+ c.currentPiStream!.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: c.turnOutput });
1081
+ }
1082
+ return;
1083
+ }
1084
+
1085
+ if (event?.type === "message_delta") {
1086
+ c.turnOutput.stopReason = mapStopReason(event.delta?.stop_reason);
1087
+ if (event.usage) updateUsage(c.turnOutput, event.usage, model);
1088
+ return;
1089
+ }
1090
+
1091
+ if (event?.type === "message_stop" && c.turnSawToolCall) {
1092
+ // Tool call complete — end this pi stream. The SDK will still yield an
1093
+ // assistant message for this turn, but currentPiStream=null causes
1094
+ // consumeQuery to skip it. The MCP handler blocks the generator until
1095
+ // pi delivers the tool result via the next streamSimple call.
1096
+ c.turnOutput.stopReason = "toolUse";
1097
+ const stream = c.currentPiStream;
1098
+ stream!.push({ type: "done", reason: "toolUse", message: c.turnOutput });
1099
+ markStreamComplete(stream);
1100
+ stream!.end();
1101
+ c.currentPiStream = null;
1102
+
1103
+ // Cursor is updated by the next streamSimple call (tool result delivery path)
1104
+ // which sets cursor = context.messages.length with the post-tool-result context.
1105
+ return;
1106
+ }
1107
+
1108
+ if (event?.type !== "message_stop" && event?.type !== "ping") {
1109
+ debug("processStreamEvent: unhandled event type", event?.type);
1110
+ }
1111
+ }
1112
+
1113
+ // The SDK always yields `assistant` messages (completed content blocks) after streaming.
1114
+ // When stream_events already delivered the content, this is a no-op. But after
1115
+ // resetTurnState (e.g. tool result delivery), if the next turn's assistant message
1116
+ // arrives before any stream_events, this is the primary content path. Must maintain
1117
+ // the same stream lifecycle as processStreamEvent — including ending the stream on
1118
+ // tool_use to prevent deadlock with the MCP handler.
1119
+ function processAssistantMessage(message: CbMessage, model: Model<any>, customToolNameToPi: Map<string, string>, c: QueryContext): void {
1120
+ if (c.turnSawStreamEvent) return;
1121
+ const assistantMsg = (message as any).message;
1122
+ if (!assistantMsg?.content) return;
1123
+ c.turnToolCallIds = [];
1124
+ c.nextHandlerIdx = 0;
1125
+ debug(`processAssistantMessage fallback: ${assistantMsg.content.length} blocks, types=${assistantMsg.content.map((b: any) => b.type).join(",")}`);
1126
+ for (const block of assistantMsg.content) {
1127
+ if (block.type === "text" && block.text) {
1128
+ ensureTurnStarted(c);
1129
+ c.turnBlocks.push({ type: "text", text: block.text });
1130
+ const idx = c.turnBlocks.length - 1;
1131
+ c.currentPiStream?.push({ type: "text_start", contentIndex: idx, partial: c.turnOutput });
1132
+ c.currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: block.text, partial: c.turnOutput });
1133
+ c.currentPiStream?.push({ type: "text_end", contentIndex: idx, content: block.text, partial: c.turnOutput });
1134
+ } else if (block.type === "thinking") {
1135
+ ensureTurnStarted(c);
1136
+ c.turnBlocks.push({ type: "thinking", thinking: block.thinking ?? "", thinkingSignature: block.signature ?? "" });
1137
+ const idx = c.turnBlocks.length - 1;
1138
+ c.currentPiStream?.push({ type: "thinking_start", contentIndex: idx, partial: c.turnOutput });
1139
+ if (block.thinking) c.currentPiStream?.push({ type: "thinking_delta", contentIndex: idx, delta: block.thinking, partial: c.turnOutput });
1140
+ c.currentPiStream?.push({ type: "thinking_end", contentIndex: idx, content: block.thinking ?? "", partial: c.turnOutput });
1141
+ } else if (block.type === "tool_use") {
1142
+ ensureTurnStarted(c);
1143
+ c.turnSawToolCall = true;
1144
+ c.turnToolCallIds.push(block.id);
1145
+ const mappedArgs = mapToolArgs(mapToolName(block.name, customToolNameToPi), block.input);
1146
+ c.turnBlocks.push({
1147
+ type: "toolCall", id: block.id,
1148
+ name: mapToolName(block.name, customToolNameToPi),
1149
+ arguments: mappedArgs,
1150
+ });
1151
+ const idx = c.turnBlocks.length - 1;
1152
+ const toolBlock = c.turnBlocks[idx];
1153
+ c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
1154
+ c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
1155
+ } else {
1156
+ debug("processAssistantMessage: unhandled block type", block.type);
1157
+ }
1158
+ }
1159
+ if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model);
1160
+
1161
+ // End the stream on tool_use, same as processStreamEvent's message_stop handler.
1162
+ if (c.turnSawToolCall && c.currentPiStream && c.turnOutput) {
1163
+ c.turnOutput.stopReason = "toolUse";
1164
+ const stream = c.currentPiStream;
1165
+ stream.push({ type: "done", reason: "toolUse", message: c.turnOutput });
1166
+ markStreamComplete(stream);
1167
+ stream.end();
1168
+ c.currentPiStream = null;
1169
+ }
1170
+ }
1171
+
1172
+ /** Background consumer: iterates the SDK generator, pushing events to currentPiStream.
1173
+ * Runs until the query ends. Per turn, the SDK yields stream_events (deltas), then
1174
+ * an assistant message (completed blocks). On tool_use, the stream is ended by
1175
+ * whichever path handles it first (processStreamEvent or processAssistantMessage),
1176
+ * and the MCP handler blocks the generator until pi delivers the tool result. */
1177
+ async function consumeQuery(
1178
+ sdkQuery: ReturnType<typeof query>,
1179
+ customToolNameToPi: Map<string, string>,
1180
+ model: Model<any>,
1181
+ wasAborted: () => boolean,
1182
+ queryCtx: QueryContext,
1183
+ ): Promise<{ capturedSessionId?: string }> {
1184
+ let capturedSessionId: string | undefined;
1185
+
1186
+ for await (const message of sdkQuery) {
1187
+ if (wasAborted()) break;
1188
+ if (!queryCtx.currentPiStream || !queryCtx.turnOutput) continue;
1189
+
1190
+ switch ((message as { type: string }).type) {
1191
+ case "stream_event":
1192
+ processStreamEvent(message, customToolNameToPi, model, queryCtx);
1193
+ break;
1194
+ case "assistant":
1195
+ processAssistantMessage(message, model, customToolNameToPi, queryCtx);
1196
+ break;
1197
+ case "result": {
1198
+ const resultMsg = message as Extract<CbMessage, { type: "result" }>;
1199
+ logServedContextWindow("result", message, model);
1200
+ if (!queryCtx.turnSawStreamEvent && resultMsg.subtype === "success") {
1201
+ ensureTurnStarted(queryCtx);
1202
+ const text = resultMsg.result || "";
1203
+ queryCtx.turnBlocks.push({ type: "text", text });
1204
+ const idx = queryCtx.turnBlocks.length - 1;
1205
+ queryCtx.currentPiStream?.push({ type: "text_start", contentIndex: idx, partial: queryCtx.turnOutput });
1206
+ queryCtx.currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: text, partial: queryCtx.turnOutput });
1207
+ queryCtx.currentPiStream?.push({ type: "text_end", contentIndex: idx, content: text, partial: queryCtx.turnOutput });
1208
+ }
1209
+ break;
1210
+ }
1211
+ case "system":
1212
+ if ((message as any).subtype === "init" && (message as any).session_id) {
1213
+ capturedSessionId = (message as any).session_id;
1214
+ }
1215
+ break;
1216
+ case "user":
1217
+ case "file-history-snapshot":
1218
+ break;
1219
+ case "rate_limit_event": {
1220
+ const info = (message as any).rate_limit_info;
1221
+ debug("consumeQuery: rate_limit_event", JSON.stringify(info).slice(0, 300));
1222
+ if (info?.status === "rejected") {
1223
+ const resetsAt = info.resetsAt ? new Date(info.resetsAt).toLocaleTimeString() : "unknown";
1224
+ piUI?.notify(`CodeBuddy rate limited (${info.rateLimitType ?? "unknown"}) — resets at ${resetsAt}`, "warning");
1225
+ } else if (info?.status === "allowed_warning") {
1226
+ piUI?.notify(`CodeBuddy rate limit warning: ${Math.round(info.utilization ?? 0)}% used (${info.rateLimitType ?? ""})`, "warning");
1227
+ }
1228
+ break;
1229
+ }
1230
+ default:
1231
+ debug("consumeQuery: unhandled SDK message type", message.type);
1232
+ break;
1233
+ }
1234
+ }
1235
+
1236
+ // DEBUG: trace when consumeQuery exits
1237
+ debug(`consumeQuery: for-await loop exited, wasAborted=${wasAborted()}, capturedSessionId=${capturedSessionId?.slice(0, 8) ?? "none"}`);
1238
+
1239
+ return { capturedSessionId };
1240
+ }
1241
+
1242
+ /** Provider entry point. Pi calls this for each new prompt and each tool result.
1243
+ * Two cases: tool result delivery (active query) or fresh query. */
1244
+ function streamCodebuddySdk(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
1245
+ const stream = newAssistantMessageEventStream();
1246
+
1247
+ // DEBUG: trace followUp message triggering
1248
+ const lastMsgRole = context.messages[context.messages.length - 1]?.role;
1249
+ debug(`provider: streamCodebuddySdk called, activeQuery=${!!ctx().activeQuery}, lastMsgRole=${lastMsgRole}, isReentrant=${ctx().activeQuery !== null}`);
1250
+
1251
+ const activeQuery = ctx().activeQuery !== null;
1252
+ const allResults = activeQueryContexts.size > 0 ? extractAllToolResults(context) : [];
1253
+ const resultCtx = allResults.length > 0 ? contextForToolResults(allResults) : undefined;
1254
+ const isReentrantUserQuery = activeQuery && lastMsgRole === "user" && allResults.length === 0;
1255
+ if (isReentrantUserQuery) {
1256
+ debug(`provider: active query user-only call treated as reentrant fresh query, waitingHandlers=${ctx().pendingToolCalls.size}, ctx.msgs=${context.messages.length}`);
1257
+ }
1258
+
1259
+ // --- Tool result delivery ---
1260
+ // Pi appends tool results to context and calls back. Extract this turn's results
1261
+ // (everything after the last assistant message) and match against waiting MCP
1262
+ // handlers. Results that arrive before their handler get queued in pendingResults.
1263
+ if (resultCtx) {
1264
+ claimCurrentPiStream(stream, "tool-result", resultCtx);
1265
+ resultCtx.resetTurnState(model);
1266
+ debug(`provider: tool results, ${allResults.length} results, ${resultCtx.pendingToolCalls.size} waiting handlers, ctx.msgs=${context.messages.length}`);
1267
+ for (const result of allResults) {
1268
+ const id = result.toolCallId;
1269
+ if (id && resultCtx.pendingToolCalls.has(id)) {
1270
+ const pending = resultCtx.pendingToolCalls.get(id)!;
1271
+ resultCtx.pendingToolCalls.delete(id);
1272
+ debug(`provider: resolving ${pending.toolName} [${id}]${result.isError ? " (error)" : ""} contentLen=${JSON.stringify(result.content).length}`);
1273
+ pending.resolve(result);
1274
+ } else if (id) {
1275
+ resultCtx.pendingResults.set(id, result);
1276
+ debug(`provider: queued result [${id}] (${resultCtx.pendingResults.size} pending)`);
1277
+ } else {
1278
+ debug(`WARNING: tool result without toolCallId, cannot match`);
1279
+ }
1280
+ if (resultCtx.pendingToolCalls.size > 0 && resultCtx.pendingResults.size > 0) {
1281
+ debug(`BUG: both maps non-empty! handlers=${resultCtx.pendingToolCalls.size} results=${resultCtx.pendingResults.size}`);
1282
+ }
1283
+ }
1284
+ if (resultCtx.pendingToolCalls.size > 0) {
1285
+ debug(`WARNING: ${resultCtx.pendingToolCalls.size} MCP handlers still waiting after delivering ${allResults.length} results`);
1286
+ piUI?.notify(`CodeBuddy SDK: ${resultCtx.pendingToolCalls.size} tool handler(s) still waiting — provider may be stuck`, "warning");
1287
+ }
1288
+
1289
+ // Detect user messages (steer/followUp) that pi injected into context
1290
+ // during the active query. This happens when:
1291
+ // - User sends a steer while a tool is executing; pi drains the steer
1292
+ // queue at the turn boundary and appends it to context alongside the
1293
+ // tool result, then calls the provider again.
1294
+ // - A followUp is delivered between tool-result turns.
1295
+ // The bridge can't forward these mid-query (the SDK query is in progress),
1296
+ // so we save them for replay as continuation queries after consumeQuery ends.
1297
+ if (lastMsgRole === "user") {
1298
+ const userPrompt = extractUserPrompt(context.messages);
1299
+ if (userPrompt) {
1300
+ resultCtx.deferredUserMessages.push(userPrompt);
1301
+ debug(`provider: deferred user message for replay after query (len=${userPrompt.length})`);
1302
+ }
1303
+ }
1304
+
1305
+ if (sharedSession) sharedSession.cursor = context.messages.length;
1306
+ resultCtx.latestCursor = Math.max(resultCtx.latestCursor, context.messages.length);
1307
+ return stream;
1308
+ }
1309
+
1310
+ // --- Orphaned tool result (e.g. user aborted a tool call) ---
1311
+ // The query is gone but pi still delivered the result. Nothing to do — just
1312
+ // emit end_turn so pi waits for the next real user message.
1313
+ const lastMsg = context.messages[context.messages.length - 1];
1314
+ if (lastMsg?.role === "toolResult") {
1315
+ debug(`provider: orphaned tool result after abort, emitting end_turn`);
1316
+ if (sharedSession) sharedSession.cursor = context.messages.length;
1317
+ const c = ctx(); // capture current context for the microtask
1318
+ queueMicrotask(() => {
1319
+ c.resetTurnState(model);
1320
+ stream.push({ type: "done", reason: "stop", message: c.turnOutput });
1321
+ markStreamComplete(stream);
1322
+ stream.end();
1323
+ });
1324
+ return stream;
1325
+ }
1326
+
1327
+ // --- Fresh query ---
1328
+
1329
+ // 1. Determine reentrancy. Reentrant queries get their own QueryContext so
1330
+ // background subagents can run concurrently with the parent query.
1331
+ const isReentrant = activeQuery;
1332
+ const queryCtx = isReentrant ? new QueryContext() : ctx();
1333
+ debug(`provider: fresh query setup, isReentrant=${isReentrant}, activeContexts=${activeQueryContexts.size}`);
1334
+
1335
+ // 2. Fresh child context — constructor already gave us clean Maps and empty
1336
+ // arrays. For a reused top-level context, clear explicitly.
1337
+ claimCurrentPiStream(stream, "fresh-query", queryCtx);
1338
+ queryCtx.pendingToolCalls.clear();
1339
+ queryCtx.pendingResults.clear();
1340
+ queryCtx.deferredUserMessages = [];
1341
+ queryCtx.resetTurnState(model);
1342
+ queryCtx.latestCursor = 0;
1343
+
1344
+ const { mcpTools, customToolNameToSdk, customToolNameToPi } = resolveMcpTools(context, askCodebuddyToolName);
1345
+ const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
1346
+ const syncResult = syncSharedSession(context.messages, cwd, customToolNameToSdk, model.id);
1347
+ const { sessionId: resumeSessionId } = syncResult;
1348
+ const promptBlocks = extractUserPromptBlocks(context.messages);
1349
+ let promptText = extractUserPrompt(context.messages) ?? "";
1350
+
1351
+ // Guard: empty prompt means the last context message isn't a user message.
1352
+ // This should never happen with per-query state — dump diagnostics if it does.
1353
+ if (!promptText && !promptBlocks) {
1354
+ diagDump("empty_prompt", {
1355
+ contextLength: context.messages.length,
1356
+ lastMsgRole: lastMsg?.role,
1357
+ isReentrant,
1358
+ activeQueryContexts: activeQueryContexts.size,
1359
+ activeQueryExists: queryCtx.activeQuery !== null,
1360
+ sharedSession: sharedSession ? { sessionId: sharedSession.sessionId.slice(0, 8), cursor: sharedSession.cursor } : null,
1361
+ messageRoles: context.messages.map((m, i) => `[${i}]${m.role}`).join(" "),
1362
+ });
1363
+ // Recover: use a continuation prompt so the SDK doesn't send an empty text block
1364
+ promptText = "[continue]";
1365
+ }
1366
+
1367
+ const prompt: string | AsyncIterable<CbUserMessage> = promptBlocks
1368
+ ? wrapPromptStream(promptBlocks)
1369
+ : promptText;
1370
+ const mcpServers = buildMcpServers(mcpTools, queryCtx);
1371
+ const boundaryOptions = buildProviderBoundaryOptions(providerSettings);
1372
+ const appendSystemPrompt = boundaryOptions.appendSystemPrompt;
1373
+ const systemPrompt = appendSystemPrompt
1374
+ ? buildCodebuddySystemPrompt(context.systemPrompt, { availableToolNames: mcpTools.map((tool) => tool.name) })
1375
+ : undefined;
1376
+
1377
+ // Provider Path keeps CodeBuddy inside Pi's tool boundary: no built-in SDK
1378
+ // tools, strict MCP by default, and no filesystem settings while Pi's system
1379
+ // prompt override is active. Current SDK maps settingSources=undefined to
1380
+ // `--setting-sources none`; appendSystemPrompt=false is the compatibility
1381
+ // escape hatch that re-enables user/project settings by default.
1382
+ const codebuddyExecutable = providerSettings.pathToCodebuddyCode;
1383
+
1384
+ // Prefer the model's own thinkingLevelMap when present (pi-ai 0.72+ ships
1385
+ // per-model overrides — e.g. opus-4-7 wants xhigh→xhigh, not xhigh→max).
1386
+ // Fall back to our generic table for older pi-ai or unmapped levels.
1387
+ const effort = options?.reasoning
1388
+ ? ((model as any).thinkingLevelMap?.[options.reasoning] as Effort | undefined)
1389
+ ?? REASONING_TO_EFFORT[options.reasoning]
1390
+ : undefined;
1391
+
1392
+ // cliModel is the actual id sent to CodeBuddy (may carry [1m]); model.id is the
1393
+ // pi-registered id. Log cliModel so debug lines reflect what CC actually received.
1394
+ const cliModel = codebuddyModelId(model);
1395
+
1396
+ const childEnv = { ...process.env, DISABLE_AUTO_COMPACT: "1" };
1397
+ const queryOptions = buildProviderQueryOptions({
1398
+ providerSettings,
1399
+ cliModel,
1400
+ cwd,
1401
+ env: childEnv,
1402
+ systemPrompt,
1403
+ effort,
1404
+ mcpServers,
1405
+ resumeSessionId,
1406
+ codebuddyExecutable,
1407
+ debugOptions: makeCliDebugOptions("provider"),
1408
+ });
1409
+
1410
+ debug("provider: fresh query",
1411
+ `model=${cliModel} msgs=${context.messages.length} tools=${mcpTools.length}`,
1412
+ `resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
1413
+ `appendSys=${appendSystemPrompt} strictMcp=${boundaryOptions.strictMcpConfigEnabled}`,
1414
+ `promptLen=${promptText.length}${promptBlocks ? " [+images]" : ""}`);
1415
+
1416
+ // 3. Start SDK query (wait for model discovery + serialize SDK subprocess access)
1417
+ let wasAborted = false;
1418
+ let sdkQuery: ReturnType<typeof query> | undefined;
1419
+ const abortCtx = queryCtx;
1420
+
1421
+ const requestAbort = () => {
1422
+ void sdkQuery?.interrupt().catch(() => {});
1423
+ };
1424
+ const onAbort = () => {
1425
+ wasAborted = true;
1426
+ abortCtx.deferredUserMessages = [];
1427
+ for (const pending of abortCtx.pendingToolCalls.values()) { pending.resolve({ content: [{ type: "text", text: "Operation aborted" }] }); }
1428
+ abortCtx.pendingToolCalls.clear();
1429
+ abortCtx.pendingResults.clear();
1430
+ requestAbort();
1431
+ };
1432
+ if (options?.signal) {
1433
+ if (options.signal.aborted) onAbort();
1434
+ else options.signal.addEventListener("abort", onAbort, { once: true });
1435
+ }
1436
+
1437
+ void (async () => {
1438
+ await ensureModelsDiscovered();
1439
+ sdkQuery = query({ prompt, options: queryOptions });
1440
+ queryCtx.activeQuery = sdkQuery;
1441
+ activeQueryContexts.add(queryCtx);
1442
+
1443
+ try {
1444
+ const { capturedSessionId } = await consumeQuery(sdkQuery, customToolNameToPi, model, () => wasAborted, queryCtx);
1445
+ debug(`provider: consumeQuery completed, stopReason=${queryCtx.turnOutput?.stopReason}, error=${queryCtx.turnOutput?.errorMessage}, aborted=${wasAborted}`);
1446
+
1447
+ if (wasAborted || options?.signal?.aborted) {
1448
+ if (sharedSession) sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
1449
+ queryCtx.deferredUserMessages = [];
1450
+ debug(`provider: abort detected, marked sharedSession needsRebuild + forceRotate`);
1451
+ if (queryCtx.turnOutput) {
1452
+ queryCtx.turnOutput.stopReason = "aborted";
1453
+ queryCtx.turnOutput.errorMessage = "Operation aborted";
1454
+ }
1455
+ const errStream = queryCtx.currentPiStream;
1456
+ errStream?.push({ type: "error", reason: "aborted", error: queryCtx.turnOutput! });
1457
+ markStreamComplete(errStream);
1458
+ errStream?.end();
1459
+ queryCtx.currentPiStream = null;
1460
+ return;
1461
+ }
1462
+
1463
+ const sessionId = capturedSessionId ?? sharedSession?.sessionId;
1464
+ if (syncResult.preserveSharedSession) {
1465
+ if (capturedSessionId && capturedSessionId !== sharedSession?.sessionId) {
1466
+ deleteSession(capturedSessionId, cwd, process.env.CODEBUDDY_CONFIG_DIR);
1467
+ debug(`provider: query done, deleted ephemeral session ${capturedSessionId.slice(0, 8)} to preserve shared session`);
1468
+ }
1469
+ debug(`provider: query done, ignoring captured session ${capturedSessionId?.slice(0, 8) ?? "none"} to preserve shared session`);
1470
+ } else if (sessionId) {
1471
+ const cursor = Math.max(context.messages.length, queryCtx.latestCursor, sharedSession?.cursor ?? 0);
1472
+ debug(`provider: query done, session=${sessionId.slice(0, 8)}, cursor=${cursor}`);
1473
+ sharedSession = { sessionId, cursor, cwd };
1474
+ }
1475
+
1476
+ while (queryCtx.deferredUserMessages.length > 0 && !isReentrant && !wasAborted) {
1477
+ const steerPrompt = queryCtx.deferredUserMessages.shift()!;
1478
+ debug(`provider: replaying deferred user message (len=${steerPrompt.length})`);
1479
+ queryCtx.resetTurnState(model);
1480
+
1481
+ const resumeId = sharedSession?.sessionId;
1482
+ if (!resumeId) {
1483
+ debug(`WARNING: no session to resume for deferred message, dropping`);
1484
+ break;
1485
+ }
1486
+
1487
+ const contOptions = { ...queryOptions, resume: resumeId, ...makeCliDebugOptions("continuation") };
1488
+ const contQuery = query({ prompt: steerPrompt, options: contOptions });
1489
+ queryCtx.activeQuery = contQuery;
1490
+ debug(`provider: continuation query, model=${cliModel}, resume=${resumeId.slice(0, 8)}, promptLen=${steerPrompt.length}`);
1491
+
1492
+ try {
1493
+ const { capturedSessionId: contSid } = await consumeQuery(contQuery, customToolNameToPi, model, () => wasAborted, queryCtx);
1494
+ const sid = contSid ?? sharedSession?.sessionId;
1495
+ if (sid) sharedSession = { sessionId: sid, cursor: sharedSession?.cursor ?? 0, cwd };
1496
+ } catch (contError) {
1497
+ debug(`provider: continuation query error:`, contError);
1498
+ break;
1499
+ } finally {
1500
+ if (wasAborted || options?.signal?.aborted) {
1501
+ await contQuery.return().catch(() => {});
1502
+ }
1503
+ }
1504
+ }
1505
+
1506
+ if (!isReentrant) {
1507
+ debug("provider: clearing activeQuery before final stream completion");
1508
+ queryCtx.activeQuery = null;
1509
+ }
1510
+ finalizeCurrentStream(queryCtx, queryCtx.turnOutput?.stopReason);
1511
+ } catch (error) {
1512
+ debug(`provider: query error, model=${cliModel}, aborted=${Boolean(options?.signal?.aborted)}, error=`, error);
1513
+ if ((wasAborted || options?.signal?.aborted) && sharedSession) {
1514
+ sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
1515
+ } else {
1516
+ sharedSession = null;
1517
+ }
1518
+ queryCtx.deferredUserMessages = [];
1519
+ if (queryCtx.turnOutput) {
1520
+ queryCtx.turnOutput.stopReason = options?.signal?.aborted ? "aborted" : "error";
1521
+ queryCtx.turnOutput.errorMessage = error instanceof Error ? error.message : String(error);
1522
+ }
1523
+ if (!isReentrant) {
1524
+ for (const pending of queryCtx.pendingToolCalls.values()) { pending.resolve({ content: [{ type: "text", text: "Query ended" }] }); }
1525
+ queryCtx.pendingToolCalls.clear();
1526
+ queryCtx.pendingResults.clear();
1527
+ queryCtx.activeQuery = null;
1528
+ }
1529
+ const errStream = queryCtx.currentPiStream;
1530
+ errStream?.push({ type: "error", reason: (queryCtx.turnOutput?.stopReason ?? "error") as "aborted" | "error", error: queryCtx.turnOutput! });
1531
+ markStreamComplete(errStream);
1532
+ errStream?.end();
1533
+ queryCtx.currentPiStream = null;
1534
+ } finally {
1535
+ if (options?.signal) options.signal.removeEventListener("abort", onAbort);
1536
+ if (queryCtx.activeQuery === sdkQuery) {
1537
+ for (const pending of queryCtx.pendingToolCalls.values()) { pending.resolve({ content: [{ type: "text", text: "Query ended" }] }); }
1538
+ queryCtx.pendingToolCalls.clear();
1539
+ queryCtx.pendingResults.clear();
1540
+ queryCtx.activeQuery = null;
1541
+ }
1542
+ activeQueryContexts.delete(queryCtx);
1543
+ maybeRefreshProviderRegistration(`query-finished:${cliModel}`);
1544
+ }
1545
+ })();
1546
+
1547
+ return stream;
1548
+ }
1549
+
1550
+ // --- AskCodebuddy: prompt and wait ---
1551
+
1552
+ async function promptAndWait(
1553
+ prompt: string,
1554
+ mode: "full" | "read" | "none",
1555
+ toolCalls: Map<string, ToolCallState>,
1556
+ signal?: AbortSignal,
1557
+ options?: {
1558
+ systemPrompt?: string;
1559
+ appendSkills?: boolean;
1560
+ onStreamUpdate?: (responseText: string) => void;
1561
+ model?: string;
1562
+ thinking?: string;
1563
+ isolated?: boolean;
1564
+ context?: Context["messages"];
1565
+ },
1566
+ ): Promise<{ responseText: string; stopReason: string }> {
1567
+ const cwd = process.cwd();
1568
+ const requestedModel = options?.model ?? "opus";
1569
+ const model = resolveModel(requestedModel);
1570
+ const modelId = model?.id ?? requestedModel;
1571
+ const cliModel = model ? codebuddyModelId(model) : modelId;
1572
+
1573
+ // Session resume for shared mode: create a delegation-only session from Pi's
1574
+ // conversation context. Do not reuse or mutate the provider sharedSession;
1575
+ // provider sessions contain Provider Tool Guidance and Pi MCP tool history,
1576
+ // which must not leak into AskCodebuddy's Delegation Path.
1577
+ let resumeSessionId: string | null = null;
1578
+ if (!options?.isolated && options?.context?.length) {
1579
+ resumeSessionId = createDelegationSessionFromContext(options.context, cwd, modelId);
1580
+ }
1581
+
1582
+ // Mode → disallowed tools
1583
+ const disallowedTools = MODE_DISALLOWED_TOOLS[mode] ?? [];
1584
+
1585
+ const askSystemPrompt = options?.systemPrompt
1586
+ ? buildCodebuddySystemPrompt(options.systemPrompt, {
1587
+ includeSkills: options.appendSkills !== false,
1588
+ includeToolBridge: false,
1589
+ })
1590
+ : undefined;
1591
+
1592
+ // Effort
1593
+ const effort = options?.thinking && options.thinking !== "off"
1594
+ ? REASONING_TO_EFFORT[options.thinking] : undefined;
1595
+
1596
+ const codebuddyExecutable = providerSettings.pathToCodebuddyCode;
1597
+
1598
+ const extraArgs: Record<string, string | null> = {
1599
+ "strict-mcp-config": null,
1600
+ model: cliModel,
1601
+ };
1602
+
1603
+ debug("askCodebuddy:",
1604
+ `mode=${mode} model=${modelId} cliModel=${cliModel} effort=${effort ?? "default"}`,
1605
+ `isolated=${options?.isolated ?? false} resume=${resumeSessionId?.slice(0, 8) ?? "none"}`,
1606
+ `sysPrompt=${Boolean(askSystemPrompt)} promptLen=${prompt.length}`);
1607
+
1608
+ const sdkQuery = query({
1609
+ prompt,
1610
+ options: {
1611
+ cwd,
1612
+ env: { ...process.env, DISABLE_AUTO_COMPACT: "1" },
1613
+ permissionMode: "bypassPermissions",
1614
+ ...(disallowedTools.length ? { disallowedTools } : {}),
1615
+ ...(effort ? { effort } : {}),
1616
+ systemPrompt: askSystemPrompt,
1617
+ settingSources: ["user", "project"] as SettingSource[],
1618
+ extraArgs,
1619
+ ...(resumeSessionId ? { resume: resumeSessionId } : {}),
1620
+ ...(options?.isolated ? { persistSession: false } : {}),
1621
+ ...(codebuddyExecutable ? { pathToCodebuddyCode: codebuddyExecutable } : {}),
1622
+ ...makeCliDebugOptions("askclaude"),
1623
+ },
1624
+ });
1625
+
1626
+ // Abort handling
1627
+ let wasAborted = false;
1628
+ const onAbort = () => {
1629
+ wasAborted = true;
1630
+ sdkQuery.interrupt().catch(() => {});
1631
+ };
1632
+ if (signal?.aborted) { onAbort(); throw new Error("Aborted"); }
1633
+ signal?.addEventListener("abort", onAbort, { once: true });
1634
+
1635
+ let responseText = "";
1636
+ let sdkMessageCount = 0;
1637
+ let textDeltaCount = 0;
1638
+ let resultSubtype: string | undefined;
1639
+
1640
+ try {
1641
+ for await (const message of sdkQuery) {
1642
+ if (wasAborted) break;
1643
+ sdkMessageCount++;
1644
+
1645
+ switch (message.type) {
1646
+ case "stream_event": {
1647
+ const event = (message as CbMessage & { event: any }).event;
1648
+ // Text deltas → accumulate and stream
1649
+ if (event?.type === "content_block_delta" && event.delta?.type === "text_delta") {
1650
+ responseText += event.delta.text;
1651
+ textDeltaCount++;
1652
+ options?.onStreamUpdate?.(responseText);
1653
+ }
1654
+ // Tool call start → track for action summary progress
1655
+ if (event?.type === "content_block_start" && event.content_block?.type === "tool_use") {
1656
+ debug(`askCodebuddy: tool_use start: ${event.content_block.name}`);
1657
+ toolCalls.set(event.content_block.id, {
1658
+ name: mapToolName(event.content_block.name),
1659
+ status: "running",
1660
+ });
1661
+ }
1662
+ break;
1663
+ }
1664
+ case "assistant": {
1665
+ // Update tool calls with full input for action summary
1666
+ for (const block of (message as any).message?.content ?? []) {
1667
+ if (block.type === "tool_use") {
1668
+ toolCalls.set(block.id, {
1669
+ name: mapToolName(block.name),
1670
+ status: "complete",
1671
+ rawInput: block.input,
1672
+ });
1673
+ }
1674
+ }
1675
+ break;
1676
+ }
1677
+ case "result": {
1678
+ resultSubtype = message.subtype;
1679
+ const r = message as any;
1680
+ if (r.usage) {
1681
+ debug(`askCodebuddy: result usage: in=${r.usage.input_tokens} out=${r.usage.output_tokens} cacheRead=${r.usage.cache_read_input_tokens ?? 0} cacheWrite=${r.usage.cache_creation_input_tokens ?? 0} turns=${r.num_turns ?? "?"}`);
1682
+ }
1683
+ if (!responseText && message.subtype === "success" && message.result) {
1684
+ responseText = message.result;
1685
+ }
1686
+ break;
1687
+ }
1688
+ }
1689
+ }
1690
+
1691
+ const stopReason = wasAborted ? "cancelled" : "stop";
1692
+ debug(`askCodebuddy: done`,
1693
+ `stopReason=${stopReason} resultSubtype=${resultSubtype ?? "none"}`,
1694
+ `sdkMessages=${sdkMessageCount} textDeltas=${textDeltaCount} responseLen=${responseText.length}`,
1695
+ `toolCalls=${toolCalls.size}`);
1696
+ return { responseText, stopReason };
1697
+ } finally {
1698
+ signal?.removeEventListener("abort", onAbort);
1699
+ void sdkQuery.interrupt().catch(() => {});
1700
+ }
1701
+ }
1702
+
1703
+ // --- Extension registration ---
1704
+
1705
+ const DEFAULT_TOOL_DESCRIPTION_FULL = "Delegate to CodeBuddy for a second opinion or analysis (code review, architecture questions, debugging theories), or to autonomously handle a task. Defaults to read-only mode — use full mode when the user wants to delegate a task that requires changes. Prefer to handle straightforward tasks yourself.";
1706
+ const DEFAULT_TOOL_DESCRIPTION = "Delegate to CodeBuddy for a second opinion or analysis (code review, architecture questions, debugging theories). Read-only — CodeBuddy can explore the codebase but not make changes. Prefer to handle straightforward tasks yourself.";
1707
+
1708
+ const PREVIEW_MAX_CHARS = 1000;
1709
+ const PREVIEW_MAX_LINES = 6;
1710
+
1711
+ let askCodebuddyToolName = "AskCodebuddy";
1712
+ let piApi: ExtensionAPI | null = null;
1713
+ let modelsDiscovered = false;
1714
+
1715
+ let discoverInFlight: Promise<void> | null = null;
1716
+
1717
+ async function discoverModels(pi: ExtensionAPI): Promise<void> {
1718
+ await withSdkGate(async () => {
1719
+ try {
1720
+ const codebuddyExecutable = providerSettings.pathToCodebuddyCode;
1721
+ const q = query({
1722
+ prompt: " ",
1723
+ options: {
1724
+ maxTurns: 0,
1725
+ permissionMode: "bypassPermissions",
1726
+ tools: [],
1727
+ env: { ...process.env, DISABLE_AUTO_COMPACT: "1" },
1728
+ ...(codebuddyExecutable ? { pathToCodebuddyCode: codebuddyExecutable } : {}),
1729
+ ...makeCliDebugOptions("supported-models"),
1730
+ },
1731
+ });
1732
+ const supported = await q.supportedModels();
1733
+ await q.return().catch(() => {});
1734
+ if (!supported.length) return;
1735
+ MODELS = applyModelCalibrations(rawModelsFromSdk(supported as any));
1736
+ registerCurrentProvider(pi);
1737
+ modelsDiscovered = true;
1738
+ debug(`discoverModels: registered ${MODELS.length} models from SDK`);
1739
+ } catch (err) {
1740
+ debug("discoverModels: failed, using fallback models", err);
1741
+ }
1742
+ });
1743
+ }
1744
+
1745
+ async function ensureModelsDiscovered(): Promise<void> {
1746
+ if (modelsDiscovered || !piApi) return;
1747
+ discoverInFlight ??= discoverModels(piApi);
1748
+ await discoverInFlight;
1749
+ }
1750
+
1751
+ export default function (pi: ExtensionAPI) {
1752
+ piApi = pi;
1753
+ const config = loadConfig(process.cwd());
1754
+ debug("loadConfig:", JSON.stringify(config));
1755
+ providerSettings = config.provider ?? {};
1756
+ calibrationEnvironment = buildCalibrationEnvironment(providerSettings.pathToCodebuddyCode);
1757
+ calibrationCache = loadCalibrationCache();
1758
+ MODELS = applyModelCalibrations(buildModels(FALLBACK_MODELS));
1759
+
1760
+ const clearSession = (event: string) => {
1761
+ debug(`${event}: clearing session ${sharedSession?.sessionId?.slice(0, 8) ?? "none"}`);
1762
+ sharedSession = null;
1763
+
1764
+ // Clear the global streamSimple if this instance registered it.
1765
+ // This allows /reload to work — the old instance clears the flag so
1766
+ // the new instance can register fresh without wrapping stale state.
1767
+ const g = globalThis as Record<symbol, any>;
1768
+ if (g[ACTIVE_STREAM_SIMPLE_KEY] === streamCodebuddySdk) {
1769
+ debug(`${event}: clearing ACTIVE_STREAM_SIMPLE_KEY`);
1770
+ g[ACTIVE_STREAM_SIMPLE_KEY] = undefined;
1771
+ }
1772
+ };
1773
+ pi.on("session_start", (event, ctx) => {
1774
+ piUI = ctx.ui;
1775
+ if (event.reason === "new" || event.reason === "resume" || event.reason === "fork") {
1776
+ clearSession(`session_start:${event.reason}`);
1777
+ }
1778
+ });
1779
+ pi.on("session_shutdown", () => clearSession("session_shutdown"));
1780
+
1781
+ pi.on("session_before_compact", async (event, ctx) => {
1782
+ if (ctx.model?.baseUrl !== PROVIDER_ID) return undefined;
1783
+ debug(
1784
+ `session_before_compact: takeover reason=${event.reason} willRetry=${event.willRetry} ` +
1785
+ `isSplitTurn=${event.preparation.isSplitTurn} messages=${event.preparation.messagesToSummarize.length} ` +
1786
+ `turnPrefix=${event.preparation.turnPrefixMessages.length}`,
1787
+ );
1788
+ try {
1789
+ reinjectPriorCompactionFileOps(event.branchEntries, event.preparation);
1790
+ const compaction = await compact(
1791
+ event.preparation,
1792
+ ctx.model,
1793
+ undefined,
1794
+ undefined,
1795
+ event.customInstructions,
1796
+ event.signal,
1797
+ undefined,
1798
+ isolatedStreamFn,
1799
+ undefined,
1800
+ );
1801
+ debug(`session_before_compact: takeover complete summaryLen=${compaction.summary.length}`);
1802
+ return { compaction };
1803
+ } catch (err) {
1804
+ const msg = errorMessage(err);
1805
+ debug("session_before_compact: takeover failed; cancelling to avoid native compact fallback", err);
1806
+ ctx.ui?.notify?.(
1807
+ `CodeBuddy SDK compact failed (${redactForLog(msg)}). Retry, switch model, or reduce context.`,
1808
+ "error",
1809
+ );
1810
+ return { cancel: true };
1811
+ }
1812
+ });
1813
+
1814
+ // pi /compact and session-tree navigation (rewind / fork-at-point /
1815
+ // branch switch) both mutate pi's messages array out from under the
1816
+ // bridge. syncSharedSession's REUSE check would otherwise see
1817
+ // slice(cursor) === [] (or skip entries) and keep --resume'ing a CC
1818
+ // session that no longer matches pi's history. /compact in particular
1819
+ // triggers CC's autocompact-thrashing guard (issue #8). Force the next
1820
+ // call down the REBUILD path so CC sees the current history.
1821
+ const markRebuild = (event: string) => {
1822
+ if (sharedSession) {
1823
+ debug(`${event}: marking needsRebuild on session ${sharedSession.sessionId.slice(0, 8)}`);
1824
+ sharedSession = { ...sharedSession, needsRebuild: true };
1825
+ }
1826
+ };
1827
+ pi.on("session_compact", (event) => markRebuild(`session_compact:${event.reason}:willRetry=${event.willRetry}`));
1828
+ pi.on("session_tree", () => markRebuild("session_tree"));
1829
+
1830
+ // --- Provider ---
1831
+ //
1832
+ // Guard against re-registration when the module is loaded multiple times
1833
+ // (e.g., when spawning subagents). The shared ModelRegistry would otherwise
1834
+ // overwrite the parent's streamSimple, breaking tool result delivery.
1835
+ // See ACTIVE_STREAM_SIMPLE_KEY for the full mechanism.
1836
+
1837
+ const g = globalThis as Record<symbol, any>;
1838
+ if (!g[ACTIVE_STREAM_SIMPLE_KEY]) {
1839
+ // First instance: store our streamSimple and register.
1840
+ g[ACTIVE_STREAM_SIMPLE_KEY] = streamCodebuddySdk;
1841
+ registerCurrentProvider(pi);
1842
+ discoverInFlight = discoverModels(pi);
1843
+ } else {
1844
+ // Subsequent instance (subagent session): skip registration entirely.
1845
+ // The subagent already has access to codebuddy-sdk models via the shared
1846
+ // ModelRegistry from the parent's registration. Calls to those models
1847
+ // route through the parent's streamSimple via reentrant QueryContexts.
1848
+ debug(`provider: skipping re-registration, parent instance active (module=${moduleInstanceId})`);
1849
+ }
1850
+
1851
+ // --- AskCodebuddy tool ---
1852
+
1853
+ const askConf = config.askCodebuddy;
1854
+ const allowFull = askConf?.allowFullMode !== false;
1855
+ const defaultMode = askConf?.defaultMode ?? "read";
1856
+ const defaultIsolated = askConf?.defaultIsolated ?? false;
1857
+ askCodebuddyToolName = askConf?.name ?? "AskCodebuddy";
1858
+
1859
+ const modeValues = allowFull ? ["read", "full", "none"] as const : ["read", "none"] as const;
1860
+ let modeDesc = `"read" (default): questions about the codebase — review, analysis, explain. "none": general knowledge only (no file access).`;
1861
+ if (allowFull) modeDesc += ` "full": allows writing and bash execution (careful: runs without feedback to pi).`;
1862
+
1863
+ if (askConf?.enabled !== false) {
1864
+ const askCodebuddyParams = Type.Object({
1865
+ prompt: Type.String({ description: "The question or task for CodeBuddy. By default Claude sees the full conversation history. Don't research up front, let Claude explore." }),
1866
+ mode: Type.Optional(StringEnum(modeValues, { description: modeDesc })),
1867
+ model: Type.Optional(Type.String({ description: 'Claude model (e.g. "opus", "sonnet", "haiku", or full ID). Defaults to "opus".' })),
1868
+ thinking: Type.Optional(StringEnum(["off", "minimal", "low", "medium", "high", "xhigh"] as const, { description: "Thinking effort level. Omit to use CodeBuddy's default." })),
1869
+ isolated: Type.Optional(Type.Boolean({ description: "When true, Claude sees only this prompt (clean session). When false (default), Claude sees the full conversation history." })),
1870
+ });
1871
+ pi.registerTool<typeof askCodebuddyParams>({
1872
+ name: askConf?.name ?? "AskCodebuddy",
1873
+ label: askConf?.label ?? "Ask CodeBuddy",
1874
+ description: askConf?.description ?? (allowFull ? DEFAULT_TOOL_DESCRIPTION_FULL : DEFAULT_TOOL_DESCRIPTION),
1875
+ parameters: askCodebuddyParams,
1876
+ renderCall(args, theme) {
1877
+ let text = theme.fg("mdLink", theme.bold("AskCodebuddy "));
1878
+ const mode = args.mode ?? defaultMode;
1879
+ const tags: string[] = [];
1880
+ if (mode !== defaultMode) tags.push(`mode=${mode}`);
1881
+ if (args.model) tags.push(`model=${args.model}`);
1882
+ if (args.thinking) tags.push(`thinking=${args.thinking}`);
1883
+ if (args.isolated) tags.push("isolated");
1884
+ if (tags.length) text += `${theme.fg("accent", `[${tags.join(", ")}]`)} `;
1885
+ const truncated = args.prompt.length > PREVIEW_MAX_CHARS ? args.prompt.substring(0, PREVIEW_MAX_CHARS) : args.prompt;
1886
+ const lines = truncated.split("\n").slice(0, PREVIEW_MAX_LINES);
1887
+ text += theme.fg("muted", `"${lines.join("\n")}"`);
1888
+ if (args.prompt.length > PREVIEW_MAX_CHARS || args.prompt.split("\n").length > PREVIEW_MAX_LINES) text += theme.fg("dim", " …");
1889
+ return new Text(text, 0, 0);
1890
+ },
1891
+ renderResult(result, { expanded, isPartial }, theme) {
1892
+ if (isPartial) {
1893
+ const status = result.content[0]?.type === "text" ? result.content[0].text : "working...";
1894
+ return new Text(theme.fg("mdLink", "◉ CodeBuddy ") + theme.fg("muted", status), 0, 0);
1895
+ }
1896
+
1897
+ const details = result.details as { prompt?: string; executionTime?: number; actions?: string; error?: boolean } | undefined;
1898
+ const body = result.content[0]?.type === "text" ? result.content[0].text : "";
1899
+
1900
+ let text = details?.error
1901
+ ? theme.fg("error", "✗ CodeBuddy error")
1902
+ : theme.fg("mdLink", "✓ CodeBuddy");
1903
+
1904
+ if (details?.executionTime) text += ` ${theme.fg("dim", `${(details.executionTime / 1000).toFixed(1)}s`)}`;
1905
+ if (details?.actions) text += ` ${theme.fg("muted", details.actions)}`;
1906
+
1907
+ if (expanded) {
1908
+ if (details?.prompt) text += `\n${theme.fg("dim", `Prompt: ${details.prompt}`)}`;
1909
+ if (details?.prompt && body) text += `\n${theme.fg("dim", "─".repeat(40))}`;
1910
+ if (body) text += `\n${theme.fg("toolOutput", body)}`;
1911
+ } else {
1912
+ const truncated = body.length > PREVIEW_MAX_CHARS ? body.substring(0, PREVIEW_MAX_CHARS) : body;
1913
+ const lines = truncated.split("\n").slice(0, PREVIEW_MAX_LINES);
1914
+ if (lines.length) text += `\n${theme.fg("toolOutput", lines.join("\n"))}`;
1915
+ if (body.length > PREVIEW_MAX_CHARS || body.split("\n").length > PREVIEW_MAX_LINES) text += `\n${theme.fg("dim", `… (${keyHint("app.tools.expand", "to expand")})`)}`;
1916
+
1917
+ }
1918
+
1919
+ return new Text(text, 0, 0);
1920
+ },
1921
+ async execute(_id, params, signal, onUpdate, ctx) {
1922
+ // Guard: circular delegation
1923
+ if (ctx.model?.baseUrl === PROVIDER_ID) {
1924
+ debug("askCodebuddy: blocked circular delegation (active provider is codebuddy-sdk)");
1925
+ return {
1926
+ content: [{ type: "text" as const, text: "Error: AskCodebuddy cannot be used when the active provider is codebuddy-sdk — you're already running through CodeBuddy." }],
1927
+ details: { error: true },
1928
+ };
1929
+ }
1930
+
1931
+ const mode = (params.mode ?? defaultMode) as "full" | "read" | "none";
1932
+ const isolated = params.isolated ?? defaultIsolated;
1933
+ const toolCalls = new Map<string, ToolCallState>();
1934
+ const start = Date.now();
1935
+
1936
+ const progressInterval = setInterval(() => {
1937
+ const elapsed = ((Date.now() - start) / 1000).toFixed(0);
1938
+ const summary = buildActionSummary(toolCalls);
1939
+ const status = summary ? `${elapsed}s — ${summary}` : `${elapsed}s — working...`;
1940
+ onUpdate?.({
1941
+ content: [{ type: "text", text: status }],
1942
+ details: { prompt: params.prompt, executionTime: Date.now() - start },
1943
+ });
1944
+ }, 1000);
1945
+
1946
+ try {
1947
+ const result = await promptAndWait(params.prompt, mode, toolCalls, signal, {
1948
+ systemPrompt: ctx.getSystemPrompt(),
1949
+ appendSkills: askConf?.appendSkills,
1950
+ model: params.model,
1951
+ thinking: params.thinking,
1952
+ isolated,
1953
+ context: isolated ? undefined : buildSessionContext(ctx.sessionManager.getBranch()).messages as Context["messages"],
1954
+ });
1955
+ clearInterval(progressInterval);
1956
+ onUpdate?.({ content: [{ type: "text", text: "" }], details: {} });
1957
+ const executionTime = Date.now() - start;
1958
+ const actions = buildActionSummary(toolCalls);
1959
+
1960
+ const text = actions
1961
+ ? `${result.responseText}\n\n[CodeBuddy actions: ${actions}]`
1962
+ : result.responseText;
1963
+ return {
1964
+ content: [{ type: "text" as const, text }],
1965
+ details: { prompt: params.prompt, executionTime, actions },
1966
+ };
1967
+ } catch (err) {
1968
+ clearInterval(progressInterval);
1969
+ debug(`askCodebuddy error: mode=${mode}, model=${params.model ?? "default"}, isolated=${isolated}, elapsed=${((Date.now() - start) / 1000).toFixed(1)}s, error=`, err);
1970
+ const msg = errorMessage(err);
1971
+ return {
1972
+ content: [{ type: "text" as const, text: `Error: ${msg}` }],
1973
+ details: { prompt: params.prompt, executionTime: Date.now() - start, error: true },
1974
+ };
1975
+ }
1976
+ },
1977
+ });
1978
+ }
1979
+ }