pi-codebuddy-sdk 0.1.19 → 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 CHANGED
@@ -1,22 +1,23 @@
1
1
  import { calculateCost, StringEnum, type AssistantMessage, type AssistantMessageEventStream, type Context, type Model, type SimpleStreamOptions, type Tool } from "@earendil-works/pi-ai";
2
2
  import * as piAi from "@earendil-works/pi-ai";
3
-
4
3
  import { buildSessionContext, compact, keyHint, type CompactionEntry, type ExtensionAPI, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
5
- import { query, type EffortLevel, type SDKMessage, type SDKUserMessage } from "@tencent-ai/agent-sdk";
6
- import type { Base64ImageSource, ContentBlockParam, MessageParam } from "@tencent-ai/agent-sdk";
4
+ import { createSdkMcpServer, query, type Effort, type Message as CbMessage, type UserMessage as CbUserMessage } from "@tencent-ai/agent-sdk";
7
5
  import { Type } from "typebox";
8
6
  import { Text } from "@earendil-works/pi-tui";
7
+ import { createSession, deleteSession } from "./cb-session-io.js";
9
8
  import { appendFileSync, mkdirSync, realpathSync, statSync } from "fs";
10
9
  import { homedir } from "os";
11
10
  import { dirname, join } from "path";
12
11
  import { PROVIDER_ID, messageContentToText, convertPiMessages } from "./convert.js";
13
- import { buildModels, codebuddyModelId, resolveModel as _resolveModel } from "./models.js";
14
- import { extractSkillsBlock } from "./skills.js";
15
-
12
+ import { buildModels, codebuddyModelId, FALLBACK_MODELS, rawModelsFromSdk, resolveModel as _resolveModel, type PiModel } from "./models.js";
13
+ import { MCP_SERVER_NAME, MCP_TOOL_PREFIX, buildCodebuddySystemPrompt } 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
16
  import { QueryContext, ctx } from "./query-state.js";
17
17
  import { loadConfig, type Config } from "./config.js";
18
- import { extractAgentsAppend } from "./agents-md.js";
18
+ import { jsonSchemaToZodShape } from "./typebox-to-zod.js";
19
19
  import { buildActionSummary, type ToolCallState } from "./askcodebuddy-ui.js";
20
+ import { withSdkGate } from "./sdk-gate.js";
20
21
 
21
22
  // Compat (#2): use factory if available (pi-ai ≥0.66), else fall back to constructor (gsd-pi etc.)
22
23
  const _piAi = piAi as any;
@@ -26,11 +27,28 @@ const newAssistantMessageEventStream: () => AssistantMessageEventStream =
26
27
  : () => new _piAi.AssistantMessageEventStream();
27
28
 
28
29
  // --- Debug logging ---
29
- // CODEBUDDY_DEBUG=1 enables debug logging to ~/.pi/agent/codebuddy-sdk.log
30
+ // CODEBUDDY_SDK_DEBUG=1 enables local debug logs (metadata only; paths redacted; no prompt/tool bodies).
30
31
 
31
- const DEBUG = process.env.CODEBUDDY_DEBUG === "1";
32
- const DEBUG_LOG_PATH = process.env.CODEBUDDY_DEBUG_PATH || join(homedir(), ".pi", "agent", "codebuddy-sdk.log");
32
+ const DEBUG = process.env.CODEBUDDY_SDK_DEBUG === "1";
33
+ const DEBUG_LOG_PATH = process.env.CODEBUDDY_SDK_DEBUG_PATH || join(homedir(), ".pi", "agent", "codebuddy-sdk.log");
33
34
  const DIAG_LOG_PATH = join(homedir(), ".pi", "agent", "codebuddy-sdk-diag.log");
35
+ const ISSUES_URL = "https://github.com/RealAlexandreAI/pi-codebuddy-sdk/issues/new";
36
+
37
+ function redactForLog(value: string): string {
38
+ const home = homedir();
39
+ return home && value.includes(home) ? value.split(home).join("~") : value;
40
+ }
41
+
42
+ function redactLogValue(value: unknown): unknown {
43
+ if (typeof value === "string") return redactForLog(value);
44
+ if (Array.isArray(value)) return value.map(redactLogValue);
45
+ if (value && typeof value === "object") {
46
+ const out: Record<string, unknown> = {};
47
+ for (const [k, v] of Object.entries(value)) out[k] = redactLogValue(v);
48
+ return out;
49
+ }
50
+ return value;
51
+ }
34
52
 
35
53
  // Ensure log directories exist when debug is enabled
36
54
  if (DEBUG) {
@@ -49,46 +67,36 @@ function debug(...args: unknown[]) {
49
67
  if (!DEBUG) return;
50
68
  const ts = new Date().toISOString();
51
69
  const fmt = (a: unknown): string => {
52
- if (typeof a === "string") return a;
53
- if (a instanceof Error) return `${a.name}: ${a.message}${a.stack ? "\n" + a.stack : ""}`;
54
- return JSON.stringify(a);
70
+ if (typeof a === "string") return redactForLog(a);
71
+ if (a instanceof Error) return redactForLog(`${a.name}: ${a.message}`);
72
+ return redactForLog(JSON.stringify(a));
55
73
  };
56
74
  const msg = args.map(fmt).join(" ");
57
75
  appendFileSync(DEBUG_LOG_PATH, `[${ts}] [${moduleInstanceId}] ${msg}\n`);
58
76
  }
59
77
 
60
- // Per-query CLI debug capture. When CODEBUDDY_DEBUG=1, ask the CodeBuddy
61
- // CLI subprocess to write its own debug log to a file we choose, and also
62
- // forward its stderr into our debug stream. Drops straight into the real SDK's
63
- // Options — see @tencent-ai/agent-sdk sdk.d.ts:1245 (debug, debugFile,
64
- // stderr). Without this, CC's internal view of the world is invisible to us
65
- // and "No conversation found" / empty-error reports are unactionable.
78
+ // Per-query CLI debug capture. When CODEBUDDY_SDK_DEBUG=1, ask the CodeBuddy CLI
79
+ // subprocess to write its own debug log locally. We do not forward stderr (may
80
+ // contain prompt or credential hints).
66
81
  let nextCliDebugSeq = 1;
67
- function makeCliDebugOptions(tag: string): { debug?: boolean; debugFile?: string; stderr?: (data: string) => void } {
82
+ function makeCliDebugOptions(tag: string): { debug?: boolean; debugFile?: string } {
68
83
  if (!DEBUG) return {};
69
84
  const seq = nextCliDebugSeq++;
70
85
  const ts = new Date().toISOString().replace(/[:.]/g, "-");
71
- const logDir = join(dirname(DEBUG_LOG_PATH), "cc-cli-logs");
86
+ const logDir = join(dirname(DEBUG_LOG_PATH), "cb-cli-logs");
72
87
  try { mkdirSync(logDir, { recursive: true }); } catch { /* ignore */ }
73
88
  const debugFile = join(logDir, `${ts}-${tag}-${seq}.log`);
74
- debug(`cli-debug: ${tag} #${seq} → ${debugFile}`);
75
- return {
76
- debug: true,
77
- debugFile,
78
- stderr: (data: string) => {
79
- for (const line of data.split(/\r?\n/)) {
80
- if (line) debug(`[cli-stderr ${tag}#${seq}] ${line}`);
81
- }
82
- },
83
- };
89
+ debug(`cli-debug: ${tag} #${seq} → ${redactForLog(debugFile)}`);
90
+ return { debug: true, debugFile };
84
91
  }
85
92
 
86
- /** Unconditional diagnostic dump — for "should never happen" paths */
93
+ /** Diagnostic dump for "should never happen" paths — only when debug is enabled */
87
94
  function diagDump(label: string, data: Record<string, unknown>) {
95
+ if (!DEBUG) return;
88
96
  const ts = new Date().toISOString();
89
- const entry = { ts, moduleInstanceId, label, ...data };
97
+ const entry = { ts, moduleInstanceId, label, ...redactLogValue(data) as Record<string, unknown> };
90
98
  appendFileSync(DIAG_LOG_PATH, JSON.stringify(entry) + "\n");
91
- debug(`DIAG: ${label} (see ${DIAG_LOG_PATH})`);
99
+ debug(`DIAG: ${label} (see ${redactForLog(DIAG_LOG_PATH)})`);
92
100
  }
93
101
 
94
102
  // --- Constants ---
@@ -113,18 +121,16 @@ const SDK_TO_PI_TOOL_NAME: Record<string, string> = {
113
121
  read: "read", write: "write", edit: "edit", bash: "bash",
114
122
  };
115
123
 
116
- // MODELS — hardcoded for CodeBuddy provider
117
- const MODELS = buildModels([
118
- { id: "hy3-preview-agent-ioa", name: "Hunyuan 3", reasoning: false, input: ["text"], contextWindow: 131072, maxTokens: 8192 },
119
- { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", reasoning: true, input: ["text", "image"], contextWindow: 200000, maxTokens: 8192 },
120
- { id: "deepseek-v4-pro-ioa", name: "DeepSeek V4 Pro", reasoning: false, input: ["text"], contextWindow: 131072, maxTokens: 8192 },
121
- { id: "glm-5.2-ioa", name: "GLM 5.2", reasoning: false, input: ["text"], contextWindow: 131072, maxTokens: 8192 },
122
- ] as any);
124
+ let MODELS: PiModel[] = buildModels(FALLBACK_MODELS);
123
125
  let providerSettings: NonNullable<Config["provider"]> = {};
124
- let longContextSettings: any = { plan: "pro", longContextExtraUsage: false };
126
+
127
+ type ContentBlockParam =
128
+ | { type: "text"; text: string }
129
+ | { type: "image"; source: { type: "base64"; media_type: string; data: string } };
130
+ type SettingSource = "user" | "project" | "local";
125
131
 
126
132
  function resolveModel(input: string) {
127
- return _resolveModel(MODELS, input) as any;
133
+ return _resolveModel(MODELS, input);
128
134
  }
129
135
 
130
136
  // --- Error handling ---
@@ -206,29 +212,25 @@ function convertAndImportMessages(
206
212
  debug(`convertAndImportMessages: imported roles:`, anthropicMessages.map((m, i) => {
207
213
  const c = m.content;
208
214
  if (typeof c === "string") return `[${i}]${m.role}:text`;
209
- if (Array.isArray(c)) return `[${i}]${m.role}:${(c).map((b) => b.type).join("+")}`;
215
+ if (Array.isArray(c)) return `[${i}]${m.role}:${c.map((b: { type?: string }) => b.type).join("+")}`;
210
216
  return `[${i}]${m.role}:?`;
211
217
  }).join(" "));
212
218
  if (sanitizedIds.size > 0) {
213
219
  debug(`convertAndImportMessages: sanitized ${sanitizedIds.size} tool IDs:`,
214
220
  [...sanitizedIds.entries()].map(([orig, clean]) => orig === clean ? orig : `${orig}→${clean}`).join(", "));
215
221
  }
216
- // Pre-repair for debug logging; importMessages also repairs internally (idempotent).
217
- const repaired = repairToolPairing(anthropicMessages);
218
- if (repaired.length !== anthropicMessages.length) {
219
- debug(`convertAndImportMessages: repairToolPairing ${anthropicMessages.length} → ${repaired.length} msgs`);
220
- }
221
- if (repaired.length) session.importMessages(repaired);
222
+ if (messages.length) session.importPiMessages(messages);
222
223
  }
223
224
 
224
225
  // Pi doesn't pass tool results directly — it appends them to the context and calls
226
+ // the provider again. Thin wrapper over extract-tool-results.js that adds per-turn
225
227
  // debug logging at the extraction boundary.
226
228
  function extractAllToolResults(context: Context): McpResult[] {
227
229
  const { results, stopIdx } = _extractAllToolResults(context.messages as unknown as Array<{ role: string; [key: string]: unknown }>);
228
230
  debug(`extractAllToolResults: ${results.length} results from ${context.messages.length} msgs, stopped at index ${stopIdx}`);
229
231
  debug(`extractAllToolResults: all msg roles:`, context.messages.map((m, i) => `[${i}]${m.role}`).join(" "));
230
232
  for (let r = 0; r < results.length; r++) {
231
- debug(`extractAllToolResults: result[${r}] id=${results[r].toolCallId}${results[r].isError ? " ERROR" : ""} preview:`, JSON.stringify(results[r].content).slice(0, 150));
233
+ debug(`extractAllToolResults: result[${r}] id=${results[r].toolCallId}${results[r].isError ? " ERROR" : ""} contentLen=${JSON.stringify(results[r].content).length}`);
232
234
  }
233
235
  return results;
234
236
  }
@@ -269,18 +271,19 @@ function extractUserPromptBlocks(messages: Context["messages"]): ContentBlockPar
269
271
  hasImage = true;
270
272
  blocks.push({
271
273
  type: "image",
272
- source: { type: "base64", media_type: block.mimeType as Base64ImageSource["media_type"], data: block.data },
274
+ source: { type: "base64", media_type: block.mimeType, data: block.data },
273
275
  });
274
276
  }
275
277
  }
276
278
  return hasImage ? blocks : null;
277
279
  }
278
280
 
279
- async function* wrapPromptStream(blocks: ContentBlockParam[]): AsyncIterable<SDKUserMessage> {
281
+ async function* wrapPromptStream(blocks: ContentBlockParam[]): AsyncIterable<CbUserMessage> {
280
282
  yield {
281
283
  type: "user",
282
- message: { role: "user", content: blocks } as MessageParam,
284
+ session_id: "",
283
285
  parent_tool_use_id: null,
286
+ message: { role: "user", content: blocks as any },
284
287
  };
285
288
  }
286
289
 
@@ -311,8 +314,8 @@ function extractIsolatedSummaryPrompt(messages: Context["messages"]): string {
311
314
  return promptText;
312
315
  }
313
316
 
314
- function resultErrorText(message: SDKMessage): string {
315
- const result = message as SDKMessage & { subtype?: string; errors?: unknown; error?: unknown };
317
+ function resultErrorText(message: CbMessage): string {
318
+ const result = message as CbMessage & { subtype?: string; errors?: unknown; error?: unknown };
316
319
  if (Array.isArray(result.errors)) return result.errors.map(String).join("\n");
317
320
  if (typeof result.error === "string") return result.error;
318
321
  return `CodeBuddy summary failed: ${result.subtype ?? "unknown result"}`;
@@ -341,7 +344,7 @@ async function runIsolatedSummary(
341
344
  try {
342
345
  const promptText = extractIsolatedSummaryPrompt(context.messages);
343
346
  const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
344
- const claudeExecutable = loadConfig(cwd).provider?.pathToClaudeCodeExecutable;
347
+ const codebuddyExecutable = loadConfig(cwd).provider?.pathToCodebuddyCode;
345
348
  const cliModel = codebuddyModelId(model);
346
349
  debug(`compact summary: spawn model=${cliModel} registeredModel=${model.id} promptLen=${promptText.length}`);
347
350
 
@@ -349,16 +352,15 @@ async function runIsolatedSummary(
349
352
  prompt: promptText,
350
353
  options: {
351
354
  cwd,
352
- env: { ...process.env, DISABLE_AUTO_COMPACT: "1", CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1" },
355
+ env: { ...process.env, DISABLE_AUTO_COMPACT: "1" },
353
356
  tools: [],
354
357
  strictMcpConfig: true,
355
-
356
- skills: [],
358
+ settingSources: [] as SettingSource[],
357
359
  persistSession: false,
358
360
  systemPrompt: context.systemPrompt,
359
361
  model: cliModel,
360
362
  maxTurns: 1,
361
- ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
363
+ ...(codebuddyExecutable ? { pathToCodebuddyCode: codebuddyExecutable } : {}),
362
364
  ...makeCliDebugOptions("compact-summary"),
363
365
  },
364
366
  });
@@ -459,13 +461,13 @@ function verifyWrittenSession(
459
461
  for (const msg of warnings) {
460
462
  debug(`WARNING session verify: ${msg}`);
461
463
  piUI?.notify(
462
- `Session file issue: ${msg}\n` +
463
- `cwd=${cwd} realpath=${safeRealpath(cwd)} CLAUDE_CONFIG_DIR=${process.env.CLAUDE_CONFIG_DIR ?? "(unset)"}\n` +
464
- `Please copy and paste this message into a new issue at https://github.com/elidickinson/pi-codebuddy-sdk/issues/new` +
465
- (DEBUG ? ` and attach ${DEBUG_LOG_PATH}` : ` (rerun with CODEBUDDY_DEBUG=1 to capture a debug log)`),
464
+ `Session sync issue: ${msg}. ` +
465
+ `cwd=${redactForLog(cwd)}` +
466
+ (DEBUG ? ` (see ${redactForLog(DEBUG_LOG_PATH)})` : ` (set CODEBUDDY_SDK_DEBUG=1 for a local log)`) +
467
+ `. Report: ${ISSUES_URL}`,
466
468
  "warning",
467
469
  );
468
- diagDump("session_verify_fail", { msg, jsonlPath, cwd, realpath: safeRealpath(cwd), claudeConfigDir: process.env.CLAUDE_CONFIG_DIR ?? null });
470
+ diagDump("session_verify_fail", { msg, jsonlPath, cwd, realpath: safeRealpath(cwd), codebuddyConfigDir: process.env.CODEBUDDY_CONFIG_DIR ?? null });
469
471
  }
470
472
  }
471
473
 
@@ -475,7 +477,7 @@ function safeRealpath(p: string): string {
475
477
 
476
478
  // Diagnostic snapshot of where a session file was just written. Catches the
477
479
  // class of bugs where pi writes to ~/.claude/projects/<X> but CC SDK reads
478
- // from ~/.claude/projects/<Y> (symlinks, CLAUDE_CONFIG_DIR, hash mismatch).
480
+ // from ~/.claude/projects/<Y> (symlinks, CODEBUDDY_CONFIG_DIR, hash mismatch).
479
481
  function debugSessionPaths(label: string, cwd: string, jsonlPath: string): void {
480
482
  const realCwd = safeRealpath(cwd);
481
483
  let fileSize: number | null = null;
@@ -485,11 +487,10 @@ function debugSessionPaths(label: string, cwd: string, jsonlPath: string): void
485
487
  fileExists = true;
486
488
  fileSize = st.size;
487
489
  } catch { /* file may not exist yet */ }
488
- debug(`${label}: cwd=${cwd}`);
489
- if (realCwd !== cwd) debug(`${label}: realpath(cwd)=${realCwd} (DIFFERS — symlink-resolved path is what CC SDK uses)`);
490
- debug(`${label}: jsonlPath=${jsonlPath}`);
490
+ debug(`${label}: cwd=${redactForLog(cwd)}`);
491
+ if (realCwd !== cwd) debug(`${label}: realpath(cwd)=${redactForLog(realCwd)} (symlink-resolved)`);
492
+ debug(`${label}: jsonlPath=${redactForLog(jsonlPath)}`);
491
493
  debug(`${label}: fileExists=${fileExists}${fileSize != null ? ` size=${fileSize}` : ""}`);
492
- debug(`${label}: env.CLAUDE_CONFIG_DIR=${process.env.CLAUDE_CONFIG_DIR ?? "(unset)"} HOME=${process.env.HOME ?? "(unset)"}`);
493
494
  }
494
495
 
495
496
  // Two semantic paths:
@@ -570,11 +571,11 @@ function syncSharedSession(
570
571
  const preserveId = previousSessionId !== undefined && !sharedSession?.forceRotate;
571
572
  if (preserveId) {
572
573
  // Wipe prior jsonl + companion dir (no-op if nothing to wipe).
573
- deleteSession(previousSessionId!, cwd, process.env.CLAUDE_CONFIG_DIR);
574
+ deleteSession(previousSessionId!, cwd, process.env.CODEBUDDY_CONFIG_DIR);
574
575
  }
575
576
  const session = createSession({
576
577
  projectPath: cwd,
577
- claudeDir: process.env.CLAUDE_CONFIG_DIR,
578
+ codebuddyDir: process.env.CODEBUDDY_CONFIG_DIR,
578
579
  ...(preserveId ? { sessionId: previousSessionId } : {}),
579
580
  ...(modelId ? { model: modelId } : {}),
580
581
  });
@@ -675,6 +676,29 @@ function contextForToolResults(results: McpResult[]): QueryContext | undefined {
675
676
  return undefined;
676
677
  }
677
678
 
679
+ function resolveMcpTools(context: Context, excludeToolName?: string): {
680
+ mcpTools: Tool[];
681
+ customToolNameToSdk: Map<string, string>;
682
+ customToolNameToPi: Map<string, string>;
683
+ } {
684
+ const mcpTools: Tool[] = [];
685
+ const customToolNameToSdk = new Map<string, string>();
686
+ const customToolNameToPi = new Map<string, string>();
687
+
688
+ if (!context.tools) return { mcpTools, customToolNameToSdk, customToolNameToPi };
689
+
690
+ for (const tool of context.tools) {
691
+ if (tool.name === excludeToolName) continue;
692
+ const sdkName = `${MCP_TOOL_PREFIX}${tool.name}`;
693
+ mcpTools.push(tool);
694
+ customToolNameToSdk.set(tool.name, sdkName);
695
+ customToolNameToSdk.set(tool.name.toLowerCase(), sdkName);
696
+ customToolNameToPi.set(sdkName, tool.name);
697
+ customToolNameToPi.set(sdkName.toLowerCase(), tool.name);
698
+ }
699
+
700
+ return { mcpTools, customToolNameToSdk, customToolNameToPi };
701
+ }
678
702
 
679
703
  // Creates an MCP server that bridges pi tools to the SDK. Each tool handler
680
704
  // blocks on a Promise until pi delivers the tool result via streamSimple.
@@ -682,7 +706,31 @@ function contextForToolResults(results: McpResult[]): QueryContext | undefined {
682
706
  // emits tool_use blocks). Results are matched by ID, not position.
683
707
  // Handlers close over the captured `queryCtx`, ensuring they operate on the
684
708
  // correct query's state while multiple queries run concurrently.
685
- // ponytail: MCP bridge function removed
709
+ function buildMcpServers(tools: Tool[], queryCtx: QueryContext): Record<string, ReturnType<typeof createSdkMcpServer>> | undefined {
710
+ if (!tools.length) return undefined;
711
+ const mcpTools = tools.map((tool) => ({
712
+ name: tool.name,
713
+ description: tool.description,
714
+ inputSchema: jsonSchemaToZodShape(tool.parameters),
715
+ handler: async () => {
716
+ const toolCallId = queryCtx.turnToolCallIds[queryCtx.nextHandlerIdx++];
717
+ if (!toolCallId) debug(`WARNING: mcp handler ${tool.name} has no toolCallId (idx=${queryCtx.nextHandlerIdx - 1}, available=${queryCtx.turnToolCallIds.length})`);
718
+ if (toolCallId && queryCtx.pendingResults.has(toolCallId)) {
719
+ const result = queryCtx.pendingResults.get(toolCallId)!;
720
+ queryCtx.pendingResults.delete(toolCallId);
721
+ debug(`mcp handler: ${tool.name} [${toolCallId}] → resolved from queue (${queryCtx.pendingResults.size} remaining)`);
722
+ return result;
723
+ }
724
+ debug(`mcp handler: ${tool.name} [${toolCallId}] → waiting`);
725
+ return new Promise<McpResult>((resolve) => {
726
+ queryCtx.pendingToolCalls.set(toolCallId, { toolName: tool.name, resolve });
727
+ });
728
+ },
729
+ }));
730
+ const server = createSdkMcpServer({ name: MCP_SERVER_NAME, version: "1.0.0", tools: mcpTools });
731
+ return { [MCP_SERVER_NAME]: server };
732
+ }
733
+
686
734
  // --- Usage helpers ---
687
735
 
688
736
  function updateUsage(output: AssistantMessage, usage: Record<string, number | undefined>, model: Model<any>): void {
@@ -707,7 +755,7 @@ function updateUsage(output: AssistantMessage, usage: Record<string, number | un
707
755
  // match the docs — e.g. bare Opus served 200K on Pro, or [1m] not honored.
708
756
  // The result message's modelUsage is otherwise discarded; this makes the
709
757
  // gap observable. See issue #18.
710
- function logServedContextWindow(label: string, message: SDKMessage, model: Model<any>): void {
758
+ function logServedContextWindow(label: string, message: CbMessage, model: Model<any>): void {
711
759
  const modelUsage = (message as any).modelUsage as Record<string, { contextWindow?: number; maxOutputTokens?: number }> | undefined;
712
760
  if (!modelUsage) return;
713
761
  for (const [k, v] of Object.entries(modelUsage)) {
@@ -718,8 +766,8 @@ function logServedContextWindow(label: string, message: SDKMessage, model: Model
718
766
  // --- Effort level mapping ---
719
767
  // Pi reasoning levels → CC SDK effort levels
720
768
 
721
- const REASONING_TO_EFFORT: Record<string, EffortLevel> = {
722
- minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "max",
769
+ const REASONING_TO_EFFORT: Record<string, Effort> = {
770
+ minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "xhigh",
723
771
  };
724
772
 
725
773
  // --- Provider helpers: misc ---
@@ -775,7 +823,7 @@ function ensureTurnStarted(c: QueryContext): void {
775
823
 
776
824
  function finalizeCurrentStream(c: QueryContext, stopReason?: string): void {
777
825
  if (!c.currentPiStream || !c.turnOutput) return;
778
- debug(`provider: finalizeCurrentStream called, stopReason=${stopReason}, turnOutput=${JSON.stringify({stopReason: c.turnOutput!.stopReason, error: c.turnOutput!.errorMessage})}`);
826
+ debug(`provider: finalizeCurrentStream called, stopReason=${stopReason}, outputStop=${c.turnOutput!.stopReason}`);
779
827
  if (!c.turnStarted) ensureTurnStarted(c);
780
828
  const reason = stopReason === "length" ? "length" : "stop";
781
829
  const stream = c.currentPiStream;
@@ -788,14 +836,14 @@ function finalizeCurrentStream(c: QueryContext, stopReason?: string): void {
788
836
  /** Maps Anthropic stream events to pi stream events (text, thinking, toolcall).
789
837
  * On message_stop with tool_use: ends currentPiStream so pi can execute the tool. */
790
838
  function processStreamEvent(
791
- message: SDKMessage,
839
+ message: CbMessage,
792
840
  customToolNameToPi: Map<string, string>,
793
841
  model: Model<any>,
794
842
  c: QueryContext,
795
843
  ): void {
796
844
  if (!c.currentPiStream || !c.turnOutput) return;
797
845
  c.turnSawStreamEvent = true;
798
- const event = (message as SDKMessage & { event: any }).event;
846
+ const event = (message as CbMessage & { event: any }).event;
799
847
 
800
848
  if (event?.type === "message_start") {
801
849
  c.turnToolCallIds = [];
@@ -904,7 +952,7 @@ function processStreamEvent(
904
952
  // arrives before any stream_events, this is the primary content path. Must maintain
905
953
  // the same stream lifecycle as processStreamEvent — including ending the stream on
906
954
  // tool_use to prevent deadlock with the MCP handler.
907
- function processAssistantMessage(message: SDKMessage, model: Model<any>, customToolNameToPi: Map<string, string>, c: QueryContext): void {
955
+ function processAssistantMessage(message: CbMessage, model: Model<any>, customToolNameToPi: Map<string, string>, c: QueryContext): void {
908
956
  if (c.turnSawStreamEvent) return;
909
957
  const assistantMsg = (message as any).message;
910
958
  if (!assistantMsg?.content) return;
@@ -975,18 +1023,19 @@ async function consumeQuery(
975
1023
  if (wasAborted()) break;
976
1024
  if (!queryCtx.currentPiStream || !queryCtx.turnOutput) continue;
977
1025
 
978
- switch (message.type) {
1026
+ switch ((message as { type: string }).type) {
979
1027
  case "stream_event":
980
1028
  processStreamEvent(message, customToolNameToPi, model, queryCtx);
981
1029
  break;
982
1030
  case "assistant":
983
1031
  processAssistantMessage(message, model, customToolNameToPi, queryCtx);
984
1032
  break;
985
- case "result":
1033
+ case "result": {
1034
+ const resultMsg = message as Extract<CbMessage, { type: "result" }>;
986
1035
  logServedContextWindow("result", message, model);
987
- if (!queryCtx.turnSawStreamEvent && message.subtype === "success") {
1036
+ if (!queryCtx.turnSawStreamEvent && resultMsg.subtype === "success") {
988
1037
  ensureTurnStarted(queryCtx);
989
- const text = message.result || "";
1038
+ const text = resultMsg.result || "";
990
1039
  queryCtx.turnBlocks.push({ type: "text", text });
991
1040
  const idx = queryCtx.turnBlocks.length - 1;
992
1041
  queryCtx.currentPiStream?.push({ type: "text_start", contentIndex: idx, partial: queryCtx.turnOutput });
@@ -994,21 +1043,23 @@ async function consumeQuery(
994
1043
  queryCtx.currentPiStream?.push({ type: "text_end", contentIndex: idx, content: text, partial: queryCtx.turnOutput });
995
1044
  }
996
1045
  break;
1046
+ }
997
1047
  case "system":
998
1048
  if ((message as any).subtype === "init" && (message as any).session_id) {
999
1049
  capturedSessionId = (message as any).session_id;
1000
1050
  }
1001
1051
  break;
1002
1052
  case "user":
1003
- break; // SDK echo of user prompt — not needed
1053
+ case "file-history-snapshot":
1054
+ break;
1004
1055
  case "rate_limit_event": {
1005
1056
  const info = (message as any).rate_limit_info;
1006
1057
  debug("consumeQuery: rate_limit_event", JSON.stringify(info).slice(0, 300));
1007
1058
  if (info?.status === "rejected") {
1008
1059
  const resetsAt = info.resetsAt ? new Date(info.resetsAt).toLocaleTimeString() : "unknown";
1009
- piUI?.notify(`Claude rate limited (${info.rateLimitType ?? "unknown"}) — resets at ${resetsAt}`, "warning");
1060
+ piUI?.notify(`CodeBuddy rate limited (${info.rateLimitType ?? "unknown"}) — resets at ${resetsAt}`, "warning");
1010
1061
  } else if (info?.status === "allowed_warning") {
1011
- piUI?.notify(`Claude rate limit warning: ${Math.round(info.utilization ?? 0)}% used (${info.rateLimitType ?? ""})`, "warning");
1062
+ piUI?.notify(`CodeBuddy rate limit warning: ${Math.round(info.utilization ?? 0)}% used (${info.rateLimitType ?? ""})`, "warning");
1012
1063
  }
1013
1064
  break;
1014
1065
  }
@@ -1026,12 +1077,12 @@ async function consumeQuery(
1026
1077
 
1027
1078
  /** Provider entry point. Pi calls this for each new prompt and each tool result.
1028
1079
  * Two cases: tool result delivery (active query) or fresh query. */
1029
- function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
1080
+ function streamCodebuddySdk(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
1030
1081
  const stream = newAssistantMessageEventStream();
1031
1082
 
1032
1083
  // DEBUG: trace followUp message triggering
1033
1084
  const lastMsgRole = context.messages[context.messages.length - 1]?.role;
1034
- debug(`provider: streamClaudeAgentSdk called, activeQuery=${!!ctx().activeQuery}, lastMsgRole=${lastMsgRole}, isReentrant=${ctx().activeQuery !== null}`);
1085
+ debug(`provider: streamCodebuddySdk called, activeQuery=${!!ctx().activeQuery}, lastMsgRole=${lastMsgRole}, isReentrant=${ctx().activeQuery !== null}`);
1035
1086
 
1036
1087
  const activeQuery = ctx().activeQuery !== null;
1037
1088
  const allResults = activeQueryContexts.size > 0 ? extractAllToolResults(context) : [];
@@ -1054,7 +1105,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1054
1105
  if (id && resultCtx.pendingToolCalls.has(id)) {
1055
1106
  const pending = resultCtx.pendingToolCalls.get(id)!;
1056
1107
  resultCtx.pendingToolCalls.delete(id);
1057
- debug(`provider: resolving ${pending.toolName} [${id}]${result.isError ? " (error)" : ""}`, JSON.stringify(result.content).slice(0, 200));
1108
+ debug(`provider: resolving ${pending.toolName} [${id}]${result.isError ? " (error)" : ""} contentLen=${JSON.stringify(result.content).length}`);
1058
1109
  pending.resolve(result);
1059
1110
  } else if (id) {
1060
1111
  resultCtx.pendingResults.set(id, result);
@@ -1068,7 +1119,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1068
1119
  }
1069
1120
  if (resultCtx.pendingToolCalls.size > 0) {
1070
1121
  debug(`WARNING: ${resultCtx.pendingToolCalls.size} MCP handlers still waiting after delivering ${allResults.length} results`);
1071
- piUI?.notify(`Claude bridge: ${resultCtx.pendingToolCalls.size} tool handler(s) still waiting — provider may be stuck`, "warning");
1122
+ piUI?.notify(`CodeBuddy SDK: ${resultCtx.pendingToolCalls.size} tool handler(s) still waiting — provider may be stuck`, "warning");
1072
1123
  }
1073
1124
 
1074
1125
  // Detect user messages (steer/followUp) that pi injected into context
@@ -1083,7 +1134,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1083
1134
  const userPrompt = extractUserPrompt(context.messages);
1084
1135
  if (userPrompt) {
1085
1136
  resultCtx.deferredUserMessages.push(userPrompt);
1086
- debug(`provider: deferred user message for replay after query: ${userPrompt.slice(0, 60)}`);
1137
+ debug(`provider: deferred user message for replay after query (len=${userPrompt.length})`);
1087
1138
  }
1088
1139
  }
1089
1140
 
@@ -1126,10 +1177,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1126
1177
  queryCtx.resetTurnState(model);
1127
1178
  queryCtx.latestCursor = 0;
1128
1179
 
1129
- // ponytail: MCP bridge removed — CodeBuddy uses native tools
1130
- const mcpTools: any[] = [];
1131
- const customToolNameToSdk = new Map<string, string>();
1132
- const customToolNameToPi = new Map<string, string>();
1180
+ const { mcpTools, customToolNameToSdk, customToolNameToPi } = resolveMcpTools(context, askCodebuddyToolName);
1133
1181
  const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
1134
1182
  const syncResult = syncSharedSession(context.messages, cwd, customToolNameToSdk, model.id);
1135
1183
  const { sessionId: resumeSessionId } = syncResult;
@@ -1152,34 +1200,31 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1152
1200
  promptText = "[continue]";
1153
1201
  }
1154
1202
 
1155
- const prompt: string | AsyncIterable<SDKUserMessage> = promptBlocks
1203
+ const prompt: string | AsyncIterable<CbUserMessage> = promptBlocks
1156
1204
  ? wrapPromptStream(promptBlocks)
1157
1205
  : promptText;
1158
- // ponytail: MCP bridge removed
1159
- const mcpServers = {};
1206
+ const mcpServers = buildMcpServers(mcpTools, queryCtx);
1160
1207
  const appendSystemPrompt = providerSettings.appendSystemPrompt !== false;
1161
- const agentsAppend = appendSystemPrompt ? extractAgentsAppend() : undefined;
1162
- const skillsAppend = appendSystemPrompt ? extractSkillsBlock(context.systemPrompt) : undefined;
1163
- const appendParts = [agentsAppend, skillsAppend].filter((part): part is string => Boolean(part));
1164
- const systemPromptAppend = appendParts.length > 0 ? appendParts.join("\n\n") : undefined;
1208
+ const systemPrompt = appendSystemPrompt
1209
+ ? buildCodebuddySystemPrompt(context.systemPrompt)
1210
+ : undefined;
1165
1211
 
1166
1212
  // MCP auto-loading suppression: CC reads MCP servers from ~/.claude.json (top-level
1167
1213
  // + per-project) and .mcp.json. Since pi executes tools (not CC), those are pure
1168
1214
  // token overhead. --strict-mcp-config tells the binary to use ONLY mcpServers passed
1169
1215
  // programmatically and ignore filesystem MCP entries — applied unconditionally because
1170
1216
  // settingSources=undefined does NOT give isolation (the CC default loads all sources).
1171
- // ponytail: removed settingSources for CodeBuddy
1172
- const settingSources = undefined
1217
+ const settingSources: SettingSource[] | undefined = appendSystemPrompt
1173
1218
  ? undefined
1174
1219
  : providerSettings.settingSources ?? ["user", "project"];
1175
1220
  const strictMcpConfigEnabled = providerSettings.strictMcpConfig !== false;
1176
- const claudeExecutable = providerSettings.pathToClaudeCodeExecutable;
1221
+ const codebuddyExecutable = providerSettings.pathToCodebuddyCode;
1177
1222
 
1178
1223
  // Prefer the model's own thinkingLevelMap when present (pi-ai 0.72+ ships
1179
1224
  // per-model overrides — e.g. opus-4-7 wants xhigh→xhigh, not xhigh→max).
1180
1225
  // Fall back to our generic table for older pi-ai or unmapped levels.
1181
1226
  const effort = options?.reasoning
1182
- ? ((model as any).thinkingLevelMap?.[options.reasoning] as EffortLevel | undefined)
1227
+ ? ((model as any).thinkingLevelMap?.[options.reasoning] as Effort | undefined)
1183
1228
  ?? REASONING_TO_EFFORT[options.reasoning]
1184
1229
  : undefined;
1185
1230
 
@@ -1188,35 +1233,21 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1188
1233
  const cliModel = codebuddyModelId(model);
1189
1234
  const extraArgs: Record<string, string | null> = { model: cliModel };
1190
1235
  if (strictMcpConfigEnabled) extraArgs["strict-mcp-config"] = null;
1191
- // Opus 4.7 defaults thinking.display to "omitted" (empty thinking text in stream).
1192
- // Force summarized so thinking_delta events arrive. See anthropics/claude-agent-sdk-python#830.
1193
- if (effort) extraArgs["thinking-display"] = "summarized";
1194
-
1195
- // Suppress claude.ai cloud MCP servers (Figma/Canva/etc. auto-discovered via OAuth
1196
- // when the user is logged into Anthropic). These are a separate code path from
1197
- // filesystem MCP and are NOT blocked by --strict-mcp-config or settingSources=undefined.
1198
- // The native CC binary gates them on env var ENABLE_CLAUDEAI_MCP_SERVERS: setting it
1199
- // to "0"/"false"/"no"/"off" makes the loader return early before any cloud fetch.
1200
- // DISABLE_AUTO_COMPACT=1: pi owns context-management and propagates its own
1201
- // /compact via session_compact (see handler in default export). Letting CC
1202
- // also autocompact would double-flush the prompt cache and races pi's
1203
- // threshold with CC's, including CC's anti-thrashing guard (issue #8).
1204
- // Manual /compact in CC still works (we never invoke it).
1205
- const childEnv = { ...process.env, ENABLE_CLAUDEAI_MCP_SERVERS: "0", DISABLE_AUTO_COMPACT: "1" };
1236
+
1237
+ const childEnv = { ...process.env, DISABLE_AUTO_COMPACT: "1" };
1206
1238
  const queryOptions: NonNullable<Parameters<typeof query>[0]["options"]> = {
1207
1239
  cwd,
1208
1240
  env: childEnv,
1209
1241
  tools: [],
1210
1242
  permissionMode: "bypassPermissions",
1211
1243
  includePartialMessages: true,
1212
- systemPrompt: {
1213
- type: "preset", preset: "codebuddy",
1214
- append: systemPromptAppend ? systemPromptAppend : undefined,
1215
- },
1244
+ systemPrompt,
1216
1245
  extraArgs,
1217
1246
  ...(effort ? { effort } : {}),
1218
- ...(resumeSessionId ? { resume: resumeSessionId } : {}),
1219
- ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
1247
+ ...(settingSources ? { settingSources } : {}),
1248
+ ...(mcpServers ? { mcpServers } : {}),
1249
+ ...(resumeSessionId ? { resume: resumeSessionId } : {}),
1250
+ ...(codebuddyExecutable ? { pathToCodebuddyCode: codebuddyExecutable } : {}),
1220
1251
  ...makeCliDebugOptions("provider"),
1221
1252
  };
1222
1253
 
@@ -1224,26 +1255,18 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1224
1255
  `model=${cliModel} msgs=${context.messages.length} tools=${mcpTools.length}`,
1225
1256
  `resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
1226
1257
  `appendSys=${appendSystemPrompt} strictMcp=${strictMcpConfigEnabled}`,
1227
- `prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`);
1258
+ `promptLen=${promptText.length}${promptBlocks ? " [+images]" : ""}`);
1228
1259
 
1229
- // 3. Start SDK query and claim it for this context
1260
+ // 3. Start SDK query (wait for model discovery + serialize SDK subprocess access)
1230
1261
  let wasAborted = false;
1231
- const sdkQuery = query({ prompt, options: queryOptions });
1232
- queryCtx.activeQuery = sdkQuery;
1233
- activeQueryContexts.add(queryCtx);
1234
-
1235
- // 4. Capture context for abort handling
1262
+ let sdkQuery: ReturnType<typeof query> | undefined;
1236
1263
  const abortCtx = queryCtx;
1237
1264
 
1238
1265
  const requestAbort = () => {
1239
- // interrupt() asks the CLI to stop gracefully; close() kills it immediately.
1240
- // Both are needed — interrupt alone lets the current API call finish.
1241
- void sdkQuery.interrupt().catch(() => {});
1242
- try { sdkQuery.interrupt(); } catch {}
1266
+ void sdkQuery?.interrupt().catch(() => {});
1243
1267
  };
1244
1268
  const onAbort = () => {
1245
1269
  wasAborted = true;
1246
- // Prevent stale deferred messages from being replayed by parent on pop
1247
1270
  abortCtx.deferredUserMessages = [];
1248
1271
  for (const pending of abortCtx.pendingToolCalls.values()) { pending.resolve({ content: [{ type: "text", text: "Operation aborted" }] }); }
1249
1272
  abortCtx.pendingToolCalls.clear();
@@ -1255,12 +1278,16 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1255
1278
  else options.signal.addEventListener("abort", onAbort, { once: true });
1256
1279
  }
1257
1280
 
1258
- // Background consumer — runs until query ends
1259
- consumeQuery(sdkQuery, customToolNameToPi, model, () => wasAborted, queryCtx)
1260
- .then(async ({ capturedSessionId }) => {
1281
+ void (async () => {
1282
+ await ensureModelsDiscovered();
1283
+ sdkQuery = query({ prompt, options: queryOptions });
1284
+ queryCtx.activeQuery = sdkQuery;
1285
+ activeQueryContexts.add(queryCtx);
1286
+
1287
+ try {
1288
+ const { capturedSessionId } = await consumeQuery(sdkQuery, customToolNameToPi, model, () => wasAborted, queryCtx);
1261
1289
  debug(`provider: consumeQuery completed, stopReason=${queryCtx.turnOutput?.stopReason}, error=${queryCtx.turnOutput?.errorMessage}, aborted=${wasAborted}`);
1262
1290
 
1263
- // --- Abort detection in normal completion path ---
1264
1291
  if (wasAborted || options?.signal?.aborted) {
1265
1292
  if (sharedSession) sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
1266
1293
  queryCtx.deferredUserMessages = [];
@@ -1269,19 +1296,18 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1269
1296
  queryCtx.turnOutput.stopReason = "aborted";
1270
1297
  queryCtx.turnOutput.errorMessage = "Operation aborted";
1271
1298
  }
1272
- const stream = queryCtx.currentPiStream;
1273
- stream?.push({ type: "error", reason: "aborted", error: queryCtx.turnOutput! });
1274
- markStreamComplete(stream);
1275
- stream?.end();
1299
+ const errStream = queryCtx.currentPiStream;
1300
+ errStream?.push({ type: "error", reason: "aborted", error: queryCtx.turnOutput! });
1301
+ markStreamComplete(errStream);
1302
+ errStream?.end();
1276
1303
  queryCtx.currentPiStream = null;
1277
1304
  return;
1278
1305
  }
1279
1306
 
1280
- // --- Capture session ID ---
1281
1307
  const sessionId = capturedSessionId ?? sharedSession?.sessionId;
1282
1308
  if (syncResult.preserveSharedSession) {
1283
1309
  if (capturedSessionId && capturedSessionId !== sharedSession?.sessionId) {
1284
- deleteSession(capturedSessionId, cwd, process.env.CLAUDE_CONFIG_DIR);
1310
+ deleteSession(capturedSessionId, cwd, process.env.CODEBUDDY_CONFIG_DIR);
1285
1311
  debug(`provider: query done, deleted ephemeral session ${capturedSessionId.slice(0, 8)} to preserve shared session`);
1286
1312
  }
1287
1313
  debug(`provider: query done, ignoring captured session ${capturedSessionId?.slice(0, 8) ?? "none"} to preserve shared session`);
@@ -1291,49 +1317,42 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1291
1317
  sharedSession = { sessionId, cursor, cwd };
1292
1318
  }
1293
1319
 
1294
- // --- Replay deferred user messages as continuation queries ---
1295
- try {
1296
- while (queryCtx.deferredUserMessages.length > 0 && !isReentrant && !wasAborted) {
1297
- const steerPrompt = queryCtx.deferredUserMessages.shift()!;
1298
- debug(`provider: replaying deferred user message: ${steerPrompt.slice(0, 60)}`);
1299
- queryCtx.resetTurnState(model);
1300
-
1301
- const resumeId = sharedSession?.sessionId;
1302
- if (!resumeId) {
1303
- debug(`WARNING: no session to resume for deferred message, dropping`);
1304
- break;
1305
- }
1320
+ while (queryCtx.deferredUserMessages.length > 0 && !isReentrant && !wasAborted) {
1321
+ const steerPrompt = queryCtx.deferredUserMessages.shift()!;
1322
+ debug(`provider: replaying deferred user message (len=${steerPrompt.length})`);
1323
+ queryCtx.resetTurnState(model);
1306
1324
 
1307
- const contOptions = { ...queryOptions, resume: resumeId, ...makeCliDebugOptions("continuation") };
1308
- const contQuery = query({ prompt: steerPrompt, options: contOptions });
1309
- queryCtx.activeQuery = contQuery;
1325
+ const resumeId = sharedSession?.sessionId;
1326
+ if (!resumeId) {
1327
+ debug(`WARNING: no session to resume for deferred message, dropping`);
1328
+ break;
1329
+ }
1310
1330
 
1311
- debug(`provider: continuation query, model=${cliModel}, resume=${resumeId.slice(0, 8)}, prompt=${steerPrompt.slice(0, 60)}`);
1331
+ const contOptions = { ...queryOptions, resume: resumeId, ...makeCliDebugOptions("continuation") };
1332
+ const contQuery = query({ prompt: steerPrompt, options: contOptions });
1333
+ queryCtx.activeQuery = contQuery;
1334
+ debug(`provider: continuation query, model=${cliModel}, resume=${resumeId.slice(0, 8)}, promptLen=${steerPrompt.length}`);
1312
1335
 
1313
- try {
1314
- const { capturedSessionId: contSid } = await consumeQuery(contQuery, customToolNameToPi, model, () => wasAborted, queryCtx);
1315
- const sid = contSid ?? sharedSession?.sessionId;
1316
- if (sid) {
1317
- sharedSession = { sessionId: sid, cursor: sharedSession?.cursor ?? 0, cwd };
1318
- }
1319
- } catch (contError) {
1320
- debug(`provider: continuation query error:`, contError);
1321
- break;
1322
- } finally {
1323
- contQuery.interrupt();
1336
+ try {
1337
+ const { capturedSessionId: contSid } = await consumeQuery(contQuery, customToolNameToPi, model, () => wasAborted, queryCtx);
1338
+ const sid = contSid ?? sharedSession?.sessionId;
1339
+ if (sid) sharedSession = { sessionId: sid, cursor: sharedSession?.cursor ?? 0, cwd };
1340
+ } catch (contError) {
1341
+ debug(`provider: continuation query error:`, contError);
1342
+ break;
1343
+ } finally {
1344
+ if (wasAborted || options?.signal?.aborted) {
1345
+ await contQuery.return().catch(() => {});
1324
1346
  }
1325
1347
  }
1326
- } finally {
1327
- queryCtx.activeQuery = sdkQuery;
1328
1348
  }
1329
1349
 
1330
- if (!isReentrant && queryCtx.activeQuery === sdkQuery) {
1350
+ if (!isReentrant) {
1331
1351
  debug("provider: clearing activeQuery before final stream completion");
1332
1352
  queryCtx.activeQuery = null;
1333
1353
  }
1334
1354
  finalizeCurrentStream(queryCtx, queryCtx.turnOutput?.stopReason);
1335
- })
1336
- .catch((error) => {
1355
+ } catch (error) {
1337
1356
  debug(`provider: query error, model=${cliModel}, aborted=${Boolean(options?.signal?.aborted)}, error=`, error);
1338
1357
  if ((wasAborted || options?.signal?.aborted) && sharedSession) {
1339
1358
  sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
@@ -1345,31 +1364,28 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1345
1364
  queryCtx.turnOutput.stopReason = options?.signal?.aborted ? "aborted" : "error";
1346
1365
  queryCtx.turnOutput.errorMessage = error instanceof Error ? error.message : String(error);
1347
1366
  }
1348
- if (!isReentrant && queryCtx.activeQuery === sdkQuery) {
1367
+ if (!isReentrant) {
1349
1368
  for (const pending of queryCtx.pendingToolCalls.values()) { pending.resolve({ content: [{ type: "text", text: "Query ended" }] }); }
1350
1369
  queryCtx.pendingToolCalls.clear();
1351
1370
  queryCtx.pendingResults.clear();
1352
- debug("provider: clearing activeQuery before error stream completion");
1353
1371
  queryCtx.activeQuery = null;
1354
1372
  }
1355
- const stream = queryCtx.currentPiStream;
1356
- stream?.push({ type: "error", reason: (queryCtx.turnOutput?.stopReason ?? "error") as "aborted" | "error", error: queryCtx.turnOutput! });
1357
- markStreamComplete(stream);
1358
- stream?.end();
1373
+ const errStream = queryCtx.currentPiStream;
1374
+ errStream?.push({ type: "error", reason: (queryCtx.turnOutput?.stopReason ?? "error") as "aborted" | "error", error: queryCtx.turnOutput! });
1375
+ markStreamComplete(errStream);
1376
+ errStream?.end();
1359
1377
  queryCtx.currentPiStream = null;
1360
- })
1361
- .finally(() => {
1378
+ } finally {
1362
1379
  if (options?.signal) options.signal.removeEventListener("abort", onAbort);
1363
1380
  if (queryCtx.activeQuery === sdkQuery) {
1364
- // Drain pending handlers for this query
1365
1381
  for (const pending of queryCtx.pendingToolCalls.values()) { pending.resolve({ content: [{ type: "text", text: "Query ended" }] }); }
1366
1382
  queryCtx.pendingToolCalls.clear();
1367
1383
  queryCtx.pendingResults.clear();
1368
1384
  queryCtx.activeQuery = null;
1369
1385
  }
1370
1386
  activeQueryContexts.delete(queryCtx);
1371
- sdkQuery.interrupt();
1372
- });
1387
+ }
1388
+ })();
1373
1389
 
1374
1390
  return stream;
1375
1391
  }
@@ -1418,43 +1434,42 @@ async function promptAndWait(
1418
1434
  // Mode → disallowed tools
1419
1435
  const disallowedTools = MODE_DISALLOWED_TOOLS[mode] ?? [];
1420
1436
 
1421
- // Skills append
1422
- const skillsBlock = options?.appendSkills !== false && options?.systemPrompt
1423
- ? extractSkillsBlock(options.systemPrompt) : undefined;
1437
+ const askSystemPrompt = options?.systemPrompt
1438
+ ? buildCodebuddySystemPrompt(options.systemPrompt, {
1439
+ includeSkills: options.appendSkills !== false,
1440
+ })
1441
+ : undefined;
1424
1442
 
1425
1443
  // Effort
1426
1444
  const effort = options?.thinking && options.thinking !== "off"
1427
1445
  ? REASONING_TO_EFFORT[options.thinking] : undefined;
1428
1446
 
1429
- const claudeExecutable = providerSettings.pathToClaudeCodeExecutable;
1447
+ const codebuddyExecutable = providerSettings.pathToCodebuddyCode;
1430
1448
 
1431
1449
  const extraArgs: Record<string, string | null> = {
1432
1450
  "strict-mcp-config": null,
1433
1451
  model: cliModel,
1434
1452
  };
1435
- if (effort) extraArgs["thinking-display"] = "summarized";
1436
1453
 
1437
1454
  debug("askCodebuddy:",
1438
1455
  `mode=${mode} model=${modelId} cliModel=${cliModel} effort=${effort ?? "default"}`,
1439
1456
  `isolated=${options?.isolated ?? false} resume=${resumeSessionId?.slice(0, 8) ?? "none"}`,
1440
- `skills=${Boolean(skillsBlock)} promptLen=${prompt.length}`);
1457
+ `sysPrompt=${Boolean(askSystemPrompt)} promptLen=${prompt.length}`);
1441
1458
 
1442
1459
  const sdkQuery = query({
1443
1460
  prompt,
1444
1461
  options: {
1445
1462
  cwd,
1446
- env: { ...process.env, ENABLE_CLAUDEAI_MCP_SERVERS: "0", DISABLE_AUTO_COMPACT: "1" },
1463
+ env: { ...process.env, DISABLE_AUTO_COMPACT: "1" },
1447
1464
  permissionMode: "bypassPermissions",
1448
1465
  ...(disallowedTools.length ? { disallowedTools } : {}),
1449
1466
  ...(effort ? { effort } : {}),
1450
- systemPrompt: skillsBlock
1451
- ? { type: "preset", preset: "codebuddy", append: skillsBlock }
1452
- : undefined,
1453
-
1467
+ systemPrompt: askSystemPrompt,
1468
+ settingSources: ["user", "project"] as SettingSource[],
1454
1469
  extraArgs,
1455
1470
  ...(resumeSessionId ? { resume: resumeSessionId } : {}),
1456
1471
  ...(options?.isolated ? { persistSession: false } : {}),
1457
- ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
1472
+ ...(codebuddyExecutable ? { pathToCodebuddyCode: codebuddyExecutable } : {}),
1458
1473
  ...makeCliDebugOptions("askclaude"),
1459
1474
  },
1460
1475
  });
@@ -1463,7 +1478,7 @@ async function promptAndWait(
1463
1478
  let wasAborted = false;
1464
1479
  const onAbort = () => {
1465
1480
  wasAborted = true;
1466
- sdkQuery.interrupt().catch(() => { try { sdkQuery.interrupt(); } catch {} });
1481
+ sdkQuery.interrupt().catch(() => {});
1467
1482
  };
1468
1483
  if (signal?.aborted) { onAbort(); throw new Error("Aborted"); }
1469
1484
  signal?.addEventListener("abort", onAbort, { once: true });
@@ -1480,7 +1495,7 @@ async function promptAndWait(
1480
1495
 
1481
1496
  switch (message.type) {
1482
1497
  case "stream_event": {
1483
- const event = (message as SDKMessage & { event: any }).event;
1498
+ const event = (message as CbMessage & { event: any }).event;
1484
1499
  // Text deltas → accumulate and stream
1485
1500
  if (event?.type === "content_block_delta" && event.delta?.type === "text_delta") {
1486
1501
  responseText += event.delta.text;
@@ -1532,7 +1547,7 @@ async function promptAndWait(
1532
1547
  return { responseText, stopReason };
1533
1548
  } finally {
1534
1549
  signal?.removeEventListener("abort", onAbort);
1535
- sdkQuery.interrupt();
1550
+ void sdkQuery.interrupt().catch(() => {});
1536
1551
  }
1537
1552
  }
1538
1553
 
@@ -1545,22 +1560,52 @@ const PREVIEW_MAX_CHARS = 1000;
1545
1560
  const PREVIEW_MAX_LINES = 6;
1546
1561
 
1547
1562
  let askCodebuddyToolName = "AskCodebuddy";
1563
+ let piApi: ExtensionAPI | null = null;
1564
+ let modelsDiscovered = false;
1548
1565
 
1549
- export default function (pi: ExtensionAPI) {
1550
- // Disable non-essential CodeBuddy traffic (update checks, MCP registry, telemetry)
1551
- process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
1566
+ let discoverInFlight: Promise<void> | null = null;
1567
+
1568
+ async function discoverModels(pi: ExtensionAPI): Promise<void> {
1569
+ await withSdkGate(async () => {
1570
+ try {
1571
+ const q = query({ prompt: " ", options: { maxTurns: 0, permissionMode: "bypassPermissions", tools: [] } });
1572
+ const supported = await q.supportedModels();
1573
+ await q.return().catch(() => {});
1574
+ if (!supported.length) return;
1575
+ MODELS = buildModels(rawModelsFromSdk(supported as any));
1576
+ const g = globalThis as Record<symbol, any>;
1577
+ const streamFn = g[ACTIVE_STREAM_SIMPLE_KEY] ?? streamCodebuddySdk;
1578
+ pi.registerProvider(PROVIDER_ID, {
1579
+ name: "CodeBuddy",
1580
+ baseUrl: PROVIDER_ID,
1581
+ apiKey: "not-used",
1582
+ api: "codebuddy-sdk",
1583
+ models: MODELS as any,
1584
+ streamSimple: streamFn as any,
1585
+ });
1586
+ modelsDiscovered = true;
1587
+ debug(`discoverModels: registered ${MODELS.length} models from SDK`);
1588
+ } catch (err) {
1589
+ debug("discoverModels: failed, using fallback models", err);
1590
+ }
1591
+ });
1592
+ }
1593
+
1594
+ async function ensureModelsDiscovered(): Promise<void> {
1595
+ if (modelsDiscovered || !piApi) return;
1596
+ discoverInFlight ??= discoverModels(piApi);
1597
+ await discoverInFlight;
1598
+ }
1552
1599
 
1600
+ export default function (pi: ExtensionAPI) {
1601
+ piApi = pi;
1553
1602
  const config = loadConfig(process.cwd());
1554
1603
  debug("loadConfig:", JSON.stringify(config));
1555
1604
  providerSettings = config.provider ?? {};
1556
- // We need these settings to know if we're eligible for 1M context on certain models
1557
- longContextSettings = {
1558
- plan: providerSettings.plan ?? "pro",
1559
- longContextExtraUsage: providerSettings.longContextExtraUsage ?? false,
1560
- };
1561
- const registeredModels = MODELS; // ponytail: no long-context suffix for CodeBuddy
1605
+ const registeredModels = MODELS;
1606
+
1607
+ discoverInFlight = discoverModels(pi);
1562
1608
 
1563
- // Reset shared session on pi session lifecycle events
1564
1609
  const clearSession = (event: string) => {
1565
1610
  debug(`${event}: clearing session ${sharedSession?.sessionId?.slice(0, 8) ?? "none"}`);
1566
1611
  sharedSession = null;
@@ -1569,7 +1614,7 @@ export default function (pi: ExtensionAPI) {
1569
1614
  // This allows /reload to work — the old instance clears the flag so
1570
1615
  // the new instance can register fresh without wrapping stale state.
1571
1616
  const g = globalThis as Record<symbol, any>;
1572
- if (g[ACTIVE_STREAM_SIMPLE_KEY] === streamClaudeAgentSdk) {
1617
+ if (g[ACTIVE_STREAM_SIMPLE_KEY] === streamCodebuddySdk) {
1573
1618
  debug(`${event}: clearing ACTIVE_STREAM_SIMPLE_KEY`);
1574
1619
  g[ACTIVE_STREAM_SIMPLE_KEY] = undefined;
1575
1620
  }
@@ -1583,7 +1628,7 @@ export default function (pi: ExtensionAPI) {
1583
1628
  pi.on("session_shutdown", () => clearSession("session_shutdown"));
1584
1629
 
1585
1630
  pi.on("session_before_compact", async (event, ctx) => {
1586
- if (ctx.model?.baseUrl !== "codebuddy-local") return undefined;
1631
+ if (ctx.model?.baseUrl !== PROVIDER_ID) return undefined;
1587
1632
  debug(
1588
1633
  `session_before_compact: takeover reason=${event.reason} willRetry=${event.willRetry} ` +
1589
1634
  `isSplitTurn=${event.preparation.isSplitTurn} messages=${event.preparation.messagesToSummarize.length} ` +
@@ -1608,7 +1653,7 @@ export default function (pi: ExtensionAPI) {
1608
1653
  const msg = errorMessage(err);
1609
1654
  debug("session_before_compact: takeover failed; cancelling to avoid native compact fallback", err);
1610
1655
  ctx.ui?.notify?.(
1611
- `Claude bridge compact failed (${msg}); cancelled to avoid known hang. Retry, switch model, or reduce context.`,
1656
+ `CodeBuddy SDK compact failed (${redactForLog(msg)}). Retry, switch model, or reduce context.`,
1612
1657
  "error",
1613
1658
  );
1614
1659
  return { cancel: true };
@@ -1641,14 +1686,15 @@ export default function (pi: ExtensionAPI) {
1641
1686
  const g = globalThis as Record<symbol, any>;
1642
1687
  if (!g[ACTIVE_STREAM_SIMPLE_KEY]) {
1643
1688
  // First instance: store our streamSimple and register.
1644
- g[ACTIVE_STREAM_SIMPLE_KEY] = streamClaudeAgentSdk;
1689
+ g[ACTIVE_STREAM_SIMPLE_KEY] = streamCodebuddySdk;
1645
1690
  pi.registerProvider(PROVIDER_ID, {
1646
- baseUrl: "codebuddy-local",
1691
+ name: "CodeBuddy",
1692
+ baseUrl: PROVIDER_ID,
1647
1693
  apiKey: "not-used",
1648
1694
  api: "codebuddy-sdk",
1649
- models: registeredModels,
1695
+ models: registeredModels as any,
1650
1696
  // Cast: pi-ai AssistantMessageEventStream diamond dep between pi-coding-agent and pi-agent-core
1651
- streamSimple: streamClaudeAgentSdk as any,
1697
+ streamSimple: streamCodebuddySdk as any,
1652
1698
  });
1653
1699
  } else {
1654
1700
  // Subsequent instance (subagent session): skip registration entirely.
@@ -1730,7 +1776,7 @@ export default function (pi: ExtensionAPI) {
1730
1776
  },
1731
1777
  async execute(_id, params, signal, onUpdate, ctx) {
1732
1778
  // Guard: circular delegation
1733
- if (ctx.model?.baseUrl === "codebuddy-local") {
1779
+ if (ctx.model?.baseUrl === PROVIDER_ID) {
1734
1780
  debug("askCodebuddy: blocked circular delegation (active provider is codebuddy-sdk)");
1735
1781
  return {
1736
1782
  content: [{ type: "text" as const, text: "Error: AskCodebuddy cannot be used when the active provider is codebuddy-sdk — you're already running through CodeBuddy." }],