pi-opencode-cli-bridge 0.2.6 → 0.2.8

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.
Files changed (3) hide show
  1. package/README.md +7 -3
  2. package/package.json +2 -2
  3. package/src/index.ts +35 -18
package/README.md CHANGED
@@ -120,17 +120,21 @@ Without an override, discovery selects active `opencode/` models whose reported
120
120
  3. It creates a temporary OpenCode project containing a `pi-model` agent whose permissions are all `ask`. Non-interactive `opencode run` rejects those permission requests automatically, while native tool declarations remain on the model request. On 1.x, a generated `tool.execute.before` plugin provides a second guard; 2.x relies on the permission guard, not the legacy plugin.
121
121
  4. It sends the prompt over standard input to `opencode run --format json`. On 2.x, it uses a private `--standalone` server, sets the subprocess working directory instead of the removed `--dir` flag, and selects reasoning variants with `provider/model#variant` instead of `--variant`. User and tool-result images are written to temporary files and attached only when the model supports images.
122
122
  5. If the model calls a native OpenCode tool anyway, the call is still blocked. When every native call in that step has a Pi equivalent in the current tool list (`bash` or 2.x `shell`, `read`, `write`, `edit` with a single replacement, `grep`, and `glob` as `find`), the bridge stops OpenCode and returns them as Pi tool calls. Paths inside the temporary project become relative to Pi's working directory. Otherwise OpenCode continues and the model can correct itself. If it ends without recovering, the bridge makes one fresh attempt with the original transcript and a correction listing the available Pi tools. For example, when Pi has no `grep` tool, the model can request a search through Pi `bash` instead. Native tools stay blocked on both attempts, which share the turn timeout. A second failure produces an error rather than retrying indefinitely.
123
- 6. Text markers such as `<pi_tool_call>{"name":"read","arguments":{"path":"example.txt"}}</pi_tool_call>` become Pi tool calls. Tool names must be in the current Pi tool list; execution then follows Pi's normal validation and permission pipeline. DeepSeek models sometimes write their native DSML tool-call blocks (`<|DSML|invoke name="...">`) as text instead; complete blocks are converted the same way, and an incomplete one fails the turn with a diagnostic instead of ending it silently.
123
+ 6. Text markers such as `<pi_tool_call>{"name":"read","arguments":{"path":"example.txt"}}</pi_tool_call>` become Pi tool calls. Tool names must be in the current Pi tool list; execution then follows Pi's normal validation and permission pipeline. DeepSeek models sometimes write their native DSML tool-call blocks (`<|DSML|invoke name="...">`) as text instead; complete blocks are converted the same way, with incomplete blocks rejected rather than executed.
124
124
  7. Successful continuation turns reuse an OpenCode session and send only the new transcript. Changes to the model, prompt, tools or transcript prefix start a fresh session.
125
125
 
126
+ A turn containing only reasoning, with no answer or usable Pi tool call, also gets one fresh recovery attempt instead of silently ending the conversation. The model is asked to answer or request an available Pi tool; normal text answers and valid calls found in reasoning are unchanged. The bridge always captures CLI reasoning blocks internally, even when Pi thinking is off or unset, so hidden reasoning can still trigger recovery or supply a valid Pi call. This uses the CLI's display flag, not a higher model reasoning variant. Reasoning is shown in Pi only when a non-off thinking level is requested.
127
+
128
+ Rejected malformed Pi markers, invalid tool-call payloads and incomplete DeepSeek DSML calls also get a fresh attempt to regenerate the call, rather than a generic empty-response error. The bridge does not execute rejected calls or infer missing arguments; valid sibling calls and remaining prose retain their existing behavior. Unavailable Pi tools still fail without retry. Reasoning-only, malformed-call and blocked-native failures share the same single retry and remaining turn timeout. If recovery fails, Pi receives an explicit error, not another thinking-only message.
129
+
126
130
  Pi's provider instrumentation hooks follow the CLI transport, not OpenCode's internal HTTP traffic:
127
131
 
128
132
  - `onPayload` receives `{ prompt }`, the exact text written to the CLI's standard input. Returning `{ prompt: "..." }` replaces it; any other non-`undefined` value fails the turn. A turn with a payload hook always starts a fresh OpenCode session.
129
133
  - `onResponse` is called once the subprocess has started, with `status: 0` (no HTTP status exists) and the header `x-pi-transport: opencode-cli`.
130
- - `onProviderStreamEvent` receives each validated `--format json` event in order, and is awaited before the event is processed.
134
+ - `onProviderStreamEvent` receives each validated `--format json` event in order, and is awaited before the event is processed. Raw observers can receive reasoning events even when thinking is hidden in Pi; the display setting does not redact transport instrumentation.
131
135
  - Provider-scoped `env` values are passed to the subprocess. The bridge's own configuration directory and update-check settings always take precedence.
132
136
 
133
- Reasoning variants, image support and token limits follow CLI discovery. Token usage is taken from CLI events when available, otherwise estimated, and includes both attempts when native-tool recovery is needed. Session records and temporary project directories are cleaned up on a best-effort basis.
137
+ Reasoning variants, image support and token limits follow CLI discovery. Token usage is taken from CLI events when available, otherwise estimated, and includes both attempts when recovery is needed. Session records and temporary project directories are cleaned up on a best-effort basis.
134
138
 
135
139
  ## Security, privacy and limits
136
140
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-opencode-cli-bridge",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Bridge the OpenCode CLI into Pi Coding Agent",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -15,7 +15,7 @@
15
15
  "build": "tsc",
16
16
  "typecheck": "tsc --noEmit",
17
17
  "test": "npm run build && node --test --test-timeout=60000 dist/index.test.js dist/process.test.js",
18
- "test:integration": "npm run build && node --test --test-timeout=120000 dist/native-tools.integration.test.js",
18
+ "test:integration": "npm run build && node --test --test-timeout=240000 dist/native-tools.integration.test.js",
19
19
  "prepublishOnly": "npm run typecheck && npm test && npm run test:integration"
20
20
  },
21
21
  "pi": {
package/src/index.ts CHANGED
@@ -264,9 +264,9 @@ export function reasoningCliArgs(
264
264
  requestedReasoning: ModelThinkingLevel | undefined,
265
265
  map?: ThinkingLevelMap,
266
266
  ): string[] {
267
- if (!requestedReasoning || requestedReasoning === "off") return [];
268
-
267
+ // Capture blocks for recovery; --thinking controls CLI output, not model effort.
269
268
  const args = ["--thinking"];
269
+ if (!requestedReasoning || requestedReasoning === "off") return args;
270
270
  const variant = map?.[requestedReasoning];
271
271
  if (typeof variant === "string" && variant.trim()) {
272
272
  args.push("--variant", variant);
@@ -1108,11 +1108,11 @@ export function parseToolCallResponse(
1108
1108
  }
1109
1109
 
1110
1110
  if (strippedSpans.length === 0) return { ok: true, calls: parsed };
1111
- return {
1112
- ok: true,
1113
- calls: parsed,
1114
- cleanedText: stripMarkerSpans(normalizedText, strippedSpans),
1115
- };
1111
+ const cleanedText = stripMarkerSpans(normalizedText.trim(), strippedSpans);
1112
+ if (parsed.length === 0 && !cleanedText.trim()) {
1113
+ return { ok: false, rejection: { reason: "malformed_markers" } };
1114
+ }
1115
+ return { ok: true, calls: parsed, cleanedText };
1116
1116
  }
1117
1117
 
1118
1118
  export function parseToolCalls(
@@ -1148,7 +1148,7 @@ function toolCallRejectionMessage(
1148
1148
  case "invalid_payload":
1149
1149
  return 'OpenCode returned an invalid Pi tool-call payload. Each marker must contain valid JSON with a non-empty string "name" and object "arguments".';
1150
1150
  case "incomplete_dsml":
1151
- return 'OpenCode returned an incomplete DeepSeek DSML tool call. Pi tools must be requested with <pi_tool_call>{"name":"...","arguments":{}}</pi_tool_call> markers.';
1151
+ return "OpenCode returned an incomplete DeepSeek DSML tool call.";
1152
1152
  case "unavailable_tool": {
1153
1153
  const available = [...allowedToolNames].map((name) =>
1154
1154
  JSON.stringify(name),
@@ -1518,7 +1518,7 @@ function streamOpenCodeAttempt(
1518
1518
  model: Model<Api>,
1519
1519
  context: Context,
1520
1520
  options: SimpleStreamOptions | undefined,
1521
- allowNativeRecovery: boolean,
1521
+ allowRecovery: boolean,
1522
1522
  ): AssistantMessageEventStream {
1523
1523
  const stream = createAssistantMessageEventStream();
1524
1524
  const deadline = Date.now() + resolveTurnTimeoutMs(options?.timeoutMs);
@@ -1543,7 +1543,8 @@ function streamOpenCodeAttempt(
1543
1543
  let stdoutRemainder = "";
1544
1544
  let capturedSessionId: string | undefined;
1545
1545
  let opencodeToolUse: string | undefined;
1546
- let nativeToolFailure: Error | undefined;
1546
+ let recoverableFailure: Error | undefined;
1547
+ let toolCallFailure: Extract<ToolCallParseResult, { ok: false }> | undefined;
1547
1548
  // Text after the last blocked native call shows whether the model recovered in-run.
1548
1549
  let nativeTextMark = 0;
1549
1550
  let stepNativeCalls: (ParsedToolCall | undefined)[] = [];
@@ -1767,10 +1768,10 @@ function streamOpenCodeAttempt(
1767
1768
  }
1768
1769
  if (opencodeError) throw new Error(opencodeError);
1769
1770
  if (opencodeToolUse && !nativeCalls && !accumulatedText.slice(nativeTextMark).trim()) {
1770
- nativeToolFailure = new Error(
1771
- `OpenCode could not use the available Pi tools${allowNativeRecovery ? "" : " after one recovery attempt"} (blocked native tool: ${JSON.stringify(opencodeToolUse)}). Retry the request or select another OpenCode model.`,
1771
+ recoverableFailure = new Error(
1772
+ `OpenCode could not use the available Pi tools${allowRecovery ? "" : " after one recovery attempt"} (blocked native tool: ${JSON.stringify(opencodeToolUse)}). Retry the request or select another OpenCode model.`,
1772
1773
  );
1773
- throw nativeToolFailure;
1774
+ throw recoverableFailure;
1774
1775
  }
1775
1776
 
1776
1777
  const toolCallResult = parseToolCallResponse(
@@ -1778,6 +1779,7 @@ function streamOpenCodeAttempt(
1778
1779
  allowedToolNames,
1779
1780
  );
1780
1781
  if (!toolCallResult.ok) {
1782
+ toolCallFailure = toolCallResult;
1781
1783
  throw new Error(
1782
1784
  toolCallRejectionMessage(toolCallResult, allowedToolNames),
1783
1785
  );
@@ -1798,6 +1800,7 @@ function streamOpenCodeAttempt(
1798
1800
  reasoningResult = { ok: false, rejection: { reason: "malformed_markers" } };
1799
1801
  }
1800
1802
  if (!reasoningResult.ok) {
1803
+ toolCallFailure = reasoningResult;
1801
1804
  throw new Error(
1802
1805
  toolCallRejectionMessage(reasoningResult, allowedToolNames),
1803
1806
  );
@@ -1815,6 +1818,12 @@ function streamOpenCodeAttempt(
1815
1818
  );
1816
1819
  }
1817
1820
  }
1821
+ if (toolCalls.length === 0 && !displayText.trim() && accumulatedThinking.trim()) {
1822
+ recoverableFailure = new Error(
1823
+ `OpenCode returned only reasoning without an answer or Pi tool call${allowRecovery ? "" : " after one recovery attempt"}. Retry the request or select another OpenCode model.`,
1824
+ );
1825
+ throw recoverableFailure;
1826
+ }
1818
1827
  if (
1819
1828
  toolCalls.length === 0 &&
1820
1829
  !displayText.trim() &&
@@ -1860,7 +1869,7 @@ function streamOpenCodeAttempt(
1860
1869
  deleteOpenCodeSession(capturedSessionId);
1861
1870
  }
1862
1871
 
1863
- if (accumulatedThinking) {
1872
+ if (accumulatedThinking && requestedReasoning && requestedReasoning !== "off") {
1864
1873
  const thinkingIndex = output.content.length;
1865
1874
  output.content.push({ type: "thinking", thinking: accumulatedThinking });
1866
1875
  stream.push({
@@ -1937,18 +1946,26 @@ function streamOpenCodeAttempt(
1937
1946
  stream.push({ type: "done", reason: finishReason, message: output });
1938
1947
  stream.end();
1939
1948
  } catch (error) {
1949
+ if (toolCallFailure && toolCallFailure.rejection.reason !== "unavailable_tool" && error instanceof Error) {
1950
+ recoverableFailure = error;
1951
+ if (!allowRecovery) error.message += " Recovery failed after one attempt.";
1952
+ }
1940
1953
  // Any error, timeout, or abort drops the session ID so the next turn
1941
1954
  // starts fresh with the full transcript; the failed session record is
1942
1955
  // deleted best-effort and never fails the turn (#71, #75).
1943
1956
  deleteOpenCodeSession(capturedSessionId);
1944
1957
  if (piState) dropOpencodeSession(piState);
1945
1958
  const remainingMs = deadline - Date.now();
1946
- if (nativeToolFailure && error === nativeToolFailure && allowNativeRecovery &&
1947
- !options?.signal?.aborted && remainingMs > 0 && context.tools?.length) {
1959
+ if (recoverableFailure && error === recoverableFailure && allowRecovery &&
1960
+ !options?.signal?.aborted && remainingMs > 0 && (!opencodeToolUse || context.tools?.length)) {
1948
1961
  setEstimatedUsage(model, output, prompt, accumulatedText + accumulatedThinking);
1949
1962
  const correction = [
1950
- `Your previous attempt used the disabled OpenCode native tool ${JSON.stringify(opencodeToolUse)}.`,
1951
- `Available Pi tools: ${context.tools.map(tool => tool.name).join(", ")}.`,
1963
+ toolCallFailure
1964
+ ? `Your previous attempt returned a malformed Pi tool call. ${recoverableFailure.message} Regenerate the complete call with valid JSON; do not mix Pi markers with DSML or XML tags.`
1965
+ : opencodeToolUse
1966
+ ? `Your previous attempt used the disabled OpenCode native tool ${JSON.stringify(opencodeToolUse)}.`
1967
+ : "Your previous attempt ended with reasoning only, without an answer or Pi tool call. Provide a final answer or request a Pi tool, not just a plan to act.",
1968
+ `Available Pi tools: ${context.tools?.map(tool => tool.name).join(", ") || "none"}.`,
1952
1969
  "Complete the original request using only those Pi tools. Do not call any OpenCode native tool.",
1953
1970
  'Request Pi tools as <pi_tool_call>{"name":"tool_name","arguments":{}}</pi_tool_call> markers, or answer normally if no tool is needed.',
1954
1971
  "If Pi has no grep tool, request a search through Pi bash when it is available instead.",