@raoxxxwq/pi-codebuddy-sdk 0.4.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.4.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,7 +25,6 @@ export interface Config {
25
25
  provider?: {
26
26
  appendSystemPrompt?: boolean;
27
27
  settingSources?: SettingSource[];
28
- strictMcpConfig?: boolean;
29
28
  pathToCodebuddyCode?: string;
30
29
  };
31
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,14 +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 extraArgs: Record<string, string | null> = {};
704
- if (strictMcpConfigEnabled) extraArgs["strict-mcp-config"] = null;
705
705
  return {
706
706
  appendSystemPrompt,
707
- strictMcpConfigEnabled,
708
707
  tools: [] as string[],
709
- extraArgs,
708
+ extraArgs: { "strict-mcp-config": null } as Record<string, string | null>,
710
709
  settingSources: appendSystemPrompt
711
710
  ? undefined
712
711
  : settings.settingSources ?? ["user", "project"] as SettingSource[],
@@ -781,6 +780,18 @@ function hasRequiredParams(toolName: string, tools: Tool[]): boolean {
781
780
  return Array.isArray(required) && required.length > 0;
782
781
  }
783
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
+
784
795
  // Renames for CodeBuddy SDK param names that differ from pi's native names.
785
796
  // Keys not listed here pass through unchanged, so new pi params work automatically.
786
797
  const SDK_KEY_RENAMES: Record<string, Record<string, string>> = {
@@ -1135,7 +1146,6 @@ function processStreamEvent(
1135
1146
  c.turnBlocks.push({ type: "thinking", thinking: "", thinkingSignature: "", index: event.index });
1136
1147
  c.currentPiStream!.push({ type: "thinking_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
1137
1148
  } else if (event.content_block?.type === "tool_use") {
1138
- c.turnToolCallIds.push(event.content_block.id);
1139
1149
  c.turnToolCallIds.push(event.content_block.id);
1140
1150
  c.turnBlocks.push({
1141
1151
  type: "toolCall", id: event.content_block.id,
@@ -1637,22 +1647,31 @@ function streamCodebuddySdk(model: Model<any>, context: Context, options?: Simpl
1637
1647
  toolUseID: opts.toolUseID,
1638
1648
  };
1639
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
+ }
1640
1658
  return { behavior: "allow" as const, toolUseID: opts.toolUseID };
1641
1659
  };
1642
1660
 
1643
1661
  debug("provider: fresh query",
1644
1662
  `model=${cliModel} msgs=${context.messages.length} tools=${mcpTools.length}`,
1645
1663
  `resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
1646
- `appendSys=${appendSystemPrompt} strictMcp=${boundaryOptions.strictMcpConfigEnabled}`,
1664
+ `appendSys=${appendSystemPrompt} strictMcp=true`,
1647
1665
  `promptLen=${promptText.length}${promptBlocks ? " [+images]" : ""}`);
1648
1666
 
1649
1667
  // 3. Start SDK query (wait for model discovery + serialize SDK subprocess access)
1650
1668
  let wasAborted = false;
1651
1669
  let sdkQuery: ReturnType<typeof query> | undefined;
1670
+ const liveQueryRef: { current?: ReturnType<typeof query> } = {};
1652
1671
  const abortCtx = queryCtx;
1653
1672
 
1654
1673
  const requestAbort = () => {
1655
- void sdkQuery?.interrupt().catch(() => {});
1674
+ interruptLiveQuery(liveQueryRef);
1656
1675
  };
1657
1676
  const onAbort = () => {
1658
1677
  wasAborted = true;
@@ -1667,11 +1686,12 @@ function streamCodebuddySdk(model: Model<any>, context: Context, options?: Simpl
1667
1686
  else options.signal.addEventListener("abort", onAbort, { once: true });
1668
1687
  }
1669
1688
 
1670
- void (async () => {
1671
- await ensureModelsDiscovered();
1672
- sdkQuery = query({ prompt, options: queryOptions });
1673
- queryCtx.activeQuery = sdkQuery;
1674
- 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);
1675
1695
 
1676
1696
  try {
1677
1697
  const { capturedSessionId } = await consumeQuery(sdkQuery, customToolNameToPi, model, () => wasAborted, queryCtx);
@@ -1717,10 +1737,11 @@ function streamCodebuddySdk(model: Model<any>, context: Context, options?: Simpl
1717
1737
  break;
1718
1738
  }
1719
1739
 
1720
- const contOptions = { ...queryOptions, resume: resumeId, ...makeCliDebugOptions("continuation") };
1721
- const contQuery = query({ prompt: steerPrompt, options: contOptions });
1722
- queryCtx.activeQuery = contQuery;
1723
- 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}`);
1724
1745
 
1725
1746
  try {
1726
1747
  const { capturedSessionId: contSid } = await consumeQuery(contQuery, customToolNameToPi, model, () => wasAborted, queryCtx);
@@ -1766,6 +1787,7 @@ function streamCodebuddySdk(model: Model<any>, context: Context, options?: Simpl
1766
1787
  queryCtx.currentPiStream = null;
1767
1788
  } finally {
1768
1789
  if (options?.signal) options.signal.removeEventListener("abort", onAbort);
1790
+ liveQueryRef.current = undefined;
1769
1791
  if (queryCtx.activeQuery === sdkQuery) {
1770
1792
  for (const pending of queryCtx.pendingToolCalls.values()) { pending.resolve({ content: [{ type: "text", text: "Query ended" }] }); }
1771
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
@@ -71,9 +71,14 @@ export function buildPiToolBridgeInstruction(options: ToolBridgeInstructionOptio
71
71
  if (has("bash")) {
72
72
  lines.push(`- Use \`${mcpName("bash")}\` only when file tools are insufficient, for search/test/build/git information, or when command execution is requested.`);
73
73
  }
74
- if (toolNames.size > 1) {
75
- 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.");
76
- }
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.");
77
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.");
78
83
 
79
84
  return lines.join("\n");