@raoxxxwq/pi-codebuddy-sdk 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,6 +9,7 @@ Pi extension that registers **CodeBuddy** as a model provider. You keep using Pi
9
9
 
10
10
  - Exposes CodeBuddy models in Pi (`codebuddy/...` in `/model`)
11
11
  - Bridges Pi tools to the SDK (Pi still executes tools; CodeBuddy only plans and calls them)
12
+ - Enforces one bridged tool call per assistant turn on the main Provider Path for stability
12
13
  - Forwards Pi's system prompt, skills, and project `AGENTS.md` so the model acts as Pi—not as standalone CodeBuddy Code
13
14
  - Supports session resume, compaction, streaming, thinking levels, and images
14
15
  - Learns runtime-served context windows over time and keeps Pi's registered model metadata conservatively aligned with observed reality
@@ -79,34 +80,61 @@ Pick any entry prefixed with `codebuddy/` (for example `codebuddy/hy3-preview-ag
79
80
 
80
81
  Tools, skills, extensions, `/compact`, and steer behave the same as with other Pi providers.
81
82
 
83
+ ### AskCodebuddy
84
+
85
+ When the AskCodebuddy tool is enabled (default), any provider can delegate a focused sub-task to a separate CodeBuddy call. This is the **Delegation Path** — unlike the main Provider Path, CodeBuddy runs its own native tools directly (not Pi-bridged MCP tools).
86
+
87
+ - `"read"` mode (default): codebase questions — review, analysis, explain.
88
+ - `"full"` mode: allows writing and bash execution (runs without feedback to Pi — use with care).
89
+ - `"none"` mode: general knowledge only (no file access).
90
+
91
+ AskCodebuddy is blocked automatically when the active provider is already `codebuddy/...` (prevents circular delegation).
92
+
82
93
  ## Configuration
83
94
 
84
95
  **Optional.** Defaults work for most users.
85
96
 
86
- File: `~/.pi/agent/codebuddy-sdk.json` or `.pi/codebuddy-sdk.json` in a project.
97
+ File: `~/.pi/agent/codebuddy-sdk.json` (global) or `.pi/codebuddy-sdk.json` (project). Project config overrides global.
87
98
 
88
99
  ```json
89
100
  {
90
101
  "askCodebuddy": {
91
102
  "enabled": true,
92
- "allowFullMode": true
103
+ "allowFullMode": true,
104
+ "defaultMode": "read"
93
105
  },
94
106
  "provider": {
95
107
  "appendSystemPrompt": true,
96
- "strictMcpConfig": true,
97
108
  "pathToCodebuddyCode": "/path/to/codebuddy"
98
109
  }
99
110
  }
100
111
  ```
101
112
 
113
+ ### AskCodebuddy options
114
+
102
115
  | Option | Default | Meaning |
103
116
  |--------|---------|---------|
104
117
  | `askCodebuddy.enabled` | `true` | Register the AskCodebuddy delegation tool |
105
- | `askCodebuddy.allowFullMode` | `true` | Allow write-capable delegation mode |
106
- | `provider.appendSystemPrompt` | `true` | Use Pi's system prompt and Pi Tool Bridge guidance instead of CodeBuddy's default identity; disable only for debugging/compatibility |
107
- | `provider.strictMcpConfig` | `true` | Use only Pi-bridged MCP tools so Pi remains the tool execution boundary; disable only for debugging/compatibility |
118
+ | `askCodebuddy.allowFullMode` | `true` | Allow write-capable (`"full"`) delegation mode |
119
+ | `askCodebuddy.defaultMode` | `"read"` | Default delegation mode: `"read"` (file access), `"full"` (write + bash), or `"none"` (general knowledge) |
120
+ | `askCodebuddy.defaultIsolated` | `false` | Default whether the delegation runs in a clean session (no conversation history) |
121
+ | `askCodebuddy.appendSkills` | `true` | Include Pi skills block in the delegation system prompt |
122
+ | `askCodebuddy.name` | `"AskCodebuddy"` | Tool name |
123
+ | `askCodebuddy.label` | `"Ask CodeBuddy"` | Tool label shown in the Pi TUI |
124
+ | `askCodebuddy.description` | auto | Tool description shown in Pi |
125
+
126
+ ### Provider options
127
+
128
+ These control the main Provider Path (when you pick `codebuddy/...` in `/model`). Options marked **escape hatch** are not for everyday tuning — disable only for debugging or compatibility.
129
+
130
+ | Option | Default | Meaning |
131
+ |--------|---------|---------|
132
+ | `provider.appendSystemPrompt` | `true` | Use Pi's system prompt and Pi Tool Bridge guidance instead of CodeBuddy's default identity (**escape hatch** — disabling re-enables CodeBuddy filesystem settings) |
133
+ | `provider.settingSources` | `["user","project"]` | CodeBuddy filesystem settings to load; only used when `appendSystemPrompt=false` (**escape hatch**) |
108
134
  | `provider.pathToCodebuddyCode` | auto | Path to `codebuddy` when it is **not** on `PATH` |
109
135
 
136
+ The main Provider Path always runs with strict MCP enabled, so only the Pi-bridged MCP server is visible to CodeBuddy in provider mode.
137
+
110
138
  ## Privacy
111
139
 
112
140
  - The extension does **not** send conversation data to this repository or any third-party telemetry endpoint.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raoxxxwq/pi-codebuddy-sdk",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Pi extension that uses CodeBuddy (via Agent SDK) as a model provider and adds an AskCodebuddy tool.",
5
5
  "author": "raoxxxwq",
6
6
  "license": "MIT",
package/src/config.ts CHANGED
@@ -25,8 +25,6 @@ export interface Config {
25
25
  provider?: {
26
26
  appendSystemPrompt?: boolean;
27
27
  settingSources?: SettingSource[];
28
- strictMcpConfig?: boolean;
29
- serialToolCalls?: boolean;
30
28
  pathToCodebuddyCode?: string;
31
29
  };
32
30
  }
package/src/index.ts CHANGED
@@ -666,6 +666,9 @@ export const __test = {
666
666
  syncSharedSession,
667
667
  isEmptyArgs,
668
668
  hasRequiredParams,
669
+ claimSerialToolUse,
670
+ interruptLiveQuery,
671
+ processStreamEvent,
669
672
  };
670
673
 
671
674
  function createDelegationSessionFromContext(
@@ -699,16 +702,10 @@ function stripToolHistoryForDelegation(messages: Context["messages"]): Context["
699
702
 
700
703
  function buildProviderBoundaryOptions(settings: NonNullable<Config["provider"]>) {
701
704
  const appendSystemPrompt = settings.appendSystemPrompt !== false;
702
- const strictMcpConfigEnabled = settings.strictMcpConfig !== false;
703
- const serialToolCalls = settings.serialToolCalls !== false;
704
- const extraArgs: Record<string, string | null> = {};
705
- if (strictMcpConfigEnabled) extraArgs["strict-mcp-config"] = null;
706
705
  return {
707
706
  appendSystemPrompt,
708
- strictMcpConfigEnabled,
709
- serialToolCalls,
710
707
  tools: [] as string[],
711
- extraArgs,
708
+ extraArgs: { "strict-mcp-config": null } as Record<string, string | null>,
712
709
  settingSources: appendSystemPrompt
713
710
  ? undefined
714
711
  : settings.settingSources ?? ["user", "project"] as SettingSource[],
@@ -783,6 +780,18 @@ function hasRequiredParams(toolName: string, tools: Tool[]): boolean {
783
780
  return Array.isArray(required) && required.length > 0;
784
781
  }
785
782
 
783
+ function claimSerialToolUse(queryCtx: QueryContext, toolUseId: string): boolean {
784
+ if (!queryCtx.claimedToolUseId) {
785
+ queryCtx.claimedToolUseId = toolUseId;
786
+ return true;
787
+ }
788
+ return queryCtx.claimedToolUseId === toolUseId;
789
+ }
790
+
791
+ function interruptLiveQuery(ref: { current?: { interrupt?: () => Promise<void> | void } }): void {
792
+ void ref.current?.interrupt?.();
793
+ }
794
+
786
795
  // Renames for CodeBuddy SDK param names that differ from pi's native names.
787
796
  // Keys not listed here pass through unchanged, so new pi params work automatically.
788
797
  const SDK_KEY_RENAMES: Record<string, Record<string, string>> = {
@@ -1137,7 +1146,6 @@ function processStreamEvent(
1137
1146
  c.turnBlocks.push({ type: "thinking", thinking: "", thinkingSignature: "", index: event.index });
1138
1147
  c.currentPiStream!.push({ type: "thinking_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
1139
1148
  } else if (event.content_block?.type === "tool_use") {
1140
- c.turnToolCallIds.push(event.content_block.id);
1141
1149
  c.turnToolCallIds.push(event.content_block.id);
1142
1150
  c.turnBlocks.push({
1143
1151
  type: "toolCall", id: event.content_block.id,
@@ -1583,7 +1591,7 @@ function streamCodebuddySdk(model: Model<any>, context: Context, options?: Simpl
1583
1591
  const boundaryOptions = buildProviderBoundaryOptions(providerSettings);
1584
1592
  const appendSystemPrompt = boundaryOptions.appendSystemPrompt;
1585
1593
  const systemPrompt = appendSystemPrompt
1586
- ? buildCodebuddySystemPrompt(context.systemPrompt, { availableToolNames: mcpTools.map((tool) => tool.name), serialToolCalls: boundaryOptions.serialToolCalls })
1594
+ ? buildCodebuddySystemPrompt(context.systemPrompt, { availableToolNames: mcpTools.map((tool) => tool.name) })
1587
1595
  : undefined;
1588
1596
 
1589
1597
  // Provider Path keeps CodeBuddy inside Pi's tool boundary: no built-in SDK
@@ -1639,22 +1647,31 @@ function streamCodebuddySdk(model: Model<any>, context: Context, options?: Simpl
1639
1647
  toolUseID: opts.toolUseID,
1640
1648
  };
1641
1649
  }
1650
+ if (!claimSerialToolUse(queryCtx, opts.toolUseID)) {
1651
+ debug(`canUseTool: rejecting ${toolName}→${piName} — serial mode already claimed by ${queryCtx.claimedToolUseId} (toolUseId=${opts.toolUseID})`);
1652
+ return {
1653
+ behavior: "deny" as const,
1654
+ message: `Only one tool call is allowed per assistant turn. Wait for the current tool result before calling another tool in a later turn.`,
1655
+ toolUseID: opts.toolUseID,
1656
+ };
1657
+ }
1642
1658
  return { behavior: "allow" as const, toolUseID: opts.toolUseID };
1643
1659
  };
1644
1660
 
1645
1661
  debug("provider: fresh query",
1646
1662
  `model=${cliModel} msgs=${context.messages.length} tools=${mcpTools.length}`,
1647
1663
  `resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
1648
- `appendSys=${appendSystemPrompt} strictMcp=${boundaryOptions.strictMcpConfigEnabled}`,
1664
+ `appendSys=${appendSystemPrompt} strictMcp=true`,
1649
1665
  `promptLen=${promptText.length}${promptBlocks ? " [+images]" : ""}`);
1650
1666
 
1651
1667
  // 3. Start SDK query (wait for model discovery + serialize SDK subprocess access)
1652
1668
  let wasAborted = false;
1653
1669
  let sdkQuery: ReturnType<typeof query> | undefined;
1670
+ const liveQueryRef: { current?: ReturnType<typeof query> } = {};
1654
1671
  const abortCtx = queryCtx;
1655
1672
 
1656
1673
  const requestAbort = () => {
1657
- void sdkQuery?.interrupt().catch(() => {});
1674
+ interruptLiveQuery(liveQueryRef);
1658
1675
  };
1659
1676
  const onAbort = () => {
1660
1677
  wasAborted = true;
@@ -1669,11 +1686,12 @@ function streamCodebuddySdk(model: Model<any>, context: Context, options?: Simpl
1669
1686
  else options.signal.addEventListener("abort", onAbort, { once: true });
1670
1687
  }
1671
1688
 
1672
- void (async () => {
1673
- await ensureModelsDiscovered();
1674
- sdkQuery = query({ prompt, options: queryOptions });
1675
- queryCtx.activeQuery = sdkQuery;
1676
- activeQueryContexts.add(queryCtx);
1689
+ void (async () => {
1690
+ await ensureModelsDiscovered();
1691
+ sdkQuery = query({ prompt, options: queryOptions });
1692
+ liveQueryRef.current = sdkQuery;
1693
+ queryCtx.activeQuery = sdkQuery;
1694
+ activeQueryContexts.add(queryCtx);
1677
1695
 
1678
1696
  try {
1679
1697
  const { capturedSessionId } = await consumeQuery(sdkQuery, customToolNameToPi, model, () => wasAborted, queryCtx);
@@ -1719,10 +1737,11 @@ function streamCodebuddySdk(model: Model<any>, context: Context, options?: Simpl
1719
1737
  break;
1720
1738
  }
1721
1739
 
1722
- const contOptions = { ...queryOptions, resume: resumeId, ...makeCliDebugOptions("continuation") };
1723
- const contQuery = query({ prompt: steerPrompt, options: contOptions });
1724
- queryCtx.activeQuery = contQuery;
1725
- debug(`provider: continuation query, model=${cliModel}, resume=${resumeId.slice(0, 8)}, promptLen=${steerPrompt.length}`);
1740
+ const contOptions = { ...queryOptions, resume: resumeId, ...makeCliDebugOptions("continuation") };
1741
+ const contQuery = query({ prompt: steerPrompt, options: contOptions });
1742
+ liveQueryRef.current = contQuery;
1743
+ queryCtx.activeQuery = contQuery;
1744
+ debug(`provider: continuation query, model=${cliModel}, resume=${resumeId.slice(0, 8)}, promptLen=${steerPrompt.length}`);
1726
1745
 
1727
1746
  try {
1728
1747
  const { capturedSessionId: contSid } = await consumeQuery(contQuery, customToolNameToPi, model, () => wasAborted, queryCtx);
@@ -1768,6 +1787,7 @@ function streamCodebuddySdk(model: Model<any>, context: Context, options?: Simpl
1768
1787
  queryCtx.currentPiStream = null;
1769
1788
  } finally {
1770
1789
  if (options?.signal) options.signal.removeEventListener("abort", onAbort);
1790
+ liveQueryRef.current = undefined;
1771
1791
  if (queryCtx.activeQuery === sdkQuery) {
1772
1792
  for (const pending of queryCtx.pendingToolCalls.values()) { pending.resolve({ content: [{ type: "text", text: "Query ended" }] }); }
1773
1793
  queryCtx.pendingToolCalls.clear();
@@ -41,6 +41,10 @@ export class QueryContext {
41
41
  // Prevents double-deferral and lets the assistant-message path know it
42
42
  // must emit the done event after backfilling.
43
43
  doneDeferredForArgs = false;
44
+ // First toolUseID observed by canUseTool in this assistant turn.
45
+ // Any different toolUseID in the same turn is denied so provider-path
46
+ // tools always execute serially.
47
+ claimedToolUseId: string | null = null;
44
48
 
45
49
  // Per-turn (reset together)
46
50
  turnOutput: AssistantMessage | null = null;
@@ -66,6 +70,7 @@ export class QueryContext {
66
70
  this.turnSawToolCall = false;
67
71
  this.argsPendingBlocks = [];
68
72
  this.doneDeferredForArgs = false;
73
+ this.claimedToolUseId = null;
69
74
  this.matchedToolCallIds = new Set();
70
75
  // turnToolCallIds and nextHandlerIdx are NOT reset — they persist across
71
76
  // tool-result delivery callbacks within the same assistant message.
package/src/skills.ts CHANGED
@@ -20,7 +20,6 @@ const BUILTIN_TOOL_GUIDANCE: Record<string, string> = {
20
20
 
21
21
  export interface ToolBridgeInstructionOptions {
22
22
  availableToolNames?: Iterable<string>;
23
- serialToolCalls?: boolean;
24
23
  }
25
24
 
26
25
  function normalizeToolNames(names: Iterable<string> | undefined): Set<string> {
@@ -72,18 +71,14 @@ export function buildPiToolBridgeInstruction(options: ToolBridgeInstructionOptio
72
71
  if (has("bash")) {
73
72
  lines.push(`- Use \`${mcpName("bash")}\` only when file tools are insufficient, for search/test/build/git information, or when command execution is requested.`);
74
73
  }
75
- if (options.serialToolCalls !== false) {
76
- // Force serial tool calls. CodeBuddy's MCP client drops arguments for
77
- // 2nd+ parallel tool_call blocks in a single response: the
78
- // content_block_stop for those blocks never arrives, so pi finalizes a
79
- // dangling toolcall_start with empty {} args and the call fails
80
- // validation. Instructing one-tool-per-turn avoids the failure at the
81
- // source. The stream-side backfill defense cannot catch this because it
82
- // keys off content_block_stop, which never fires for the dropped call.
83
- lines.push("Call AT MOST ONE tool per turn. Never emit multiple tool_call blocks in a single response. Issue one tool call, wait for its result, then decide the next call in the following turn. Parallel tool calls are unsupported and will fail.");
84
- } else if (toolNames.size > 1) {
85
- lines.push("When calling multiple tools in a single response, ensure each tool_call has complete arguments — do not leave required fields empty or rely on defaults. Incomplete parallel tool calls will be rejected.");
86
- }
74
+ // Force serial tool calls. CodeBuddy's MCP client drops arguments for
75
+ // 2nd+ parallel tool_call blocks in a single response: the
76
+ // content_block_stop for those blocks never arrives, so pi finalizes a
77
+ // dangling toolcall_start with empty {} args and the call fails
78
+ // validation. Instructing one-tool-per-turn avoids the failure at the
79
+ // source. The stream-side backfill defense cannot catch this because it
80
+ // keys off content_block_stop, which never fires for the dropped call.
81
+ lines.push("Call AT MOST ONE tool per turn. Never emit multiple tool_call blocks in a single response. A second tool call in the same turn will be denied. Issue one tool call, wait for its result, then decide the next call in the following turn. Parallel tool calls are unsupported and will fail.");
87
82
  lines.push("Tool arguments must match the Pi tool schema exactly. After a tool result, base the next step on that result; if it is an error, correct the call instead of assuming success.");
88
83
 
89
84
  return lines.join("\n");
@@ -140,13 +135,13 @@ function applySkillsRewrite(systemPrompt: string): string {
140
135
  /** Pi system prompt as CodeBuddy override (replaces default "CodeBuddy Code" identity). */
141
136
  export function buildCodebuddySystemPrompt(
142
137
  piSystemPrompt: string | undefined,
143
- options?: { includeAgents?: boolean; includeSkills?: boolean; includeToolBridge?: boolean; availableToolNames?: Iterable<string>; serialToolCalls?: boolean },
138
+ options?: { includeAgents?: boolean; includeSkills?: boolean; includeToolBridge?: boolean; availableToolNames?: Iterable<string> },
144
139
  ): string | undefined {
145
140
  const parts: string[] = [];
146
141
  const includeToolBridge = options?.includeToolBridge !== false;
147
142
 
148
143
  if (includeToolBridge) {
149
- parts.push(buildPiToolBridgeInstruction({ availableToolNames: options?.availableToolNames, serialToolCalls: options?.serialToolCalls }));
144
+ parts.push(buildPiToolBridgeInstruction({ availableToolNames: options?.availableToolNames }));
150
145
  }
151
146
 
152
147
  if (piSystemPrompt) {