pi-opencode-cli-bridge 0.2.5 → 0.2.7

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
@@ -33,7 +33,7 @@ The original MIT copyright notice is preserved in [LICENSE](LICENSE). The new pa
33
33
 
34
34
  - Node.js **22.19.0 or newer**.
35
35
  - Pi Coding Agent **1.2.0 or newer**; the development SDK is pinned to 1.2.0.
36
- - OpenCode installed on the same machine and available on `PATH`, or selected with `OPENCODE_PI_BIN`. Tested with OpenCode **1.19.0** and **2.0.30**; other releases are unverified.
36
+ - OpenCode installed on the same machine and available on `PATH`, or selected with `OPENCODE_PI_BIN`. Supports OpenCode up to **2.0.30**. Real-CLI integration tests cover **1.19.0** and **2.0.30**; intermediate releases are not individually verified.
37
37
 
38
38
  Check OpenCode before starting Pi:
39
39
 
@@ -119,10 +119,12 @@ Without an override, discovery selects active `opencode/` models whose reported
119
119
  2. It replays Pi's transcript system messages to recover the current system prompt and tool declarations, including later changes.
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
- 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: the model sees a correction asking it to use a Pi tool, and the turn fails only if it does not recover.
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
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.
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. Reasoning-only 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.
127
+
126
128
  Pi's provider instrumentation hooks follow the CLI transport, not OpenCode's internal HTTP traffic:
127
129
 
128
130
  - `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.
@@ -130,7 +132,7 @@ Pi's provider instrumentation hooks follow the CLI transport, not OpenCode's int
130
132
  - `onProviderStreamEvent` receives each validated `--format json` event in order, and is awaited before the event is processed.
131
133
  - Provider-scoped `env` values are passed to the subprocess. The bridge's own configuration directory and update-check settings always take precedence.
132
134
 
133
- Reasoning variants, image support and token limits follow CLI discovery. Token usage is taken from CLI events when available, otherwise estimated. Session records and temporary project directories are cleaned up on a best-effort basis.
135
+ 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
136
 
135
137
  ## Security, privacy and limits
136
138
 
@@ -140,7 +142,7 @@ Reasoning variants, image support and token limits follow CLI discovery. Token u
140
142
  - Pi and OpenCode can both retain local conversation data. Best-effort cleanup is not a guarantee of secure deletion.
141
143
  - Free-tier model availability and accepted request shapes can change. This fork makes no guarantee of continued access or compliance with a hosted provider's terms; review the applicable terms yourself.
142
144
  - Live tests with DeepSeek v4.1 Flash Free used thinking off. The first high-thinking test timed out; no default thinking setting is changed by this package.
143
- - At startup Pi warns if your OpenCode version is neither 1.19.0 nor 2.0.30, the exact releases covered by the native-tool guard tests.
145
+ - At startup Pi warns when your OpenCode version exceeds the supported maximum, **2.0.30**, or its version cannot be determined.
144
146
  - Timeouts and cancellation escalate from `SIGTERM` to `SIGKILL` on the CLI's process group after 250 ms; on Windows the whole process tree is ended with `taskkill /T /F` (not tested on Windows). If OpenCode quits before reading the prompt, the turn shows OpenCode's own error rather than a broken-pipe error. Malformed or unexpected CLI events end the turn with an error instead of crashing Pi. Shutdown waits for session cleanup.
145
147
 
146
148
  See [SECURITY.md](SECURITY.md) for the trust boundaries and [docs/code-review.md](https://github.com/Gybra/pi-opencode-cli-bridge/blob/main/docs/code-review.md) for what was fixed and what remains. This is an experimental, unaudited fork.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-opencode-cli-bridge",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Bridge the OpenCode CLI into Pi Coding Agent",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -14,7 +14,7 @@
14
14
  "scripts": {
15
15
  "build": "tsc",
16
16
  "typecheck": "tsc --noEmit",
17
- "test": "npm run build && node --test --test-timeout=30000 dist/index.test.js dist/process.test.js",
17
+ "test": "npm run build && node --test --test-timeout=60000 dist/index.test.js dist/process.test.js",
18
18
  "test:integration": "npm run build && node --test --test-timeout=120000 dist/native-tools.integration.test.js",
19
19
  "prepublishOnly": "npm run typecheck && npm test && npm run test:integration"
20
20
  },
package/src/index.ts CHANGED
@@ -29,8 +29,7 @@ import {
29
29
  const PROVIDER_ID = "opencode-cli";
30
30
  const API_ID = "opencode-cli-runner";
31
31
  const AGENT_ID = "pi-model";
32
- // Only exact releases exercised by the real-CLI native-side-effect tests belong here.
33
- const VERIFIED_OPENCODE_VERSIONS = ["1.19.0", "2.0.30"];
32
+ const MAX_SUPPORTED_OPENCODE_VERSION = "2.0.30";
34
33
  const cliVersions = new Map<string, string | undefined>();
35
34
 
36
35
  async function usesV2Cli(): Promise<boolean> {
@@ -1511,8 +1510,18 @@ export function streamOpenCode(
1511
1510
  model: Model<Api>,
1512
1511
  context: Context,
1513
1512
  options?: SimpleStreamOptions,
1513
+ ): AssistantMessageEventStream {
1514
+ return streamOpenCodeAttempt(model, context, options, true);
1515
+ }
1516
+
1517
+ function streamOpenCodeAttempt(
1518
+ model: Model<Api>,
1519
+ context: Context,
1520
+ options: SimpleStreamOptions | undefined,
1521
+ allowRecovery: boolean,
1514
1522
  ): AssistantMessageEventStream {
1515
1523
  const stream = createAssistantMessageEventStream();
1524
+ const deadline = Date.now() + resolveTurnTimeoutMs(options?.timeoutMs);
1516
1525
 
1517
1526
  (async () => {
1518
1527
  const output: AssistantMessage = {
@@ -1527,12 +1536,14 @@ export function streamOpenCode(
1527
1536
  };
1528
1537
 
1529
1538
  let tempDir: string | undefined;
1539
+ let prompt = "";
1530
1540
  let accumulatedText = "";
1531
1541
  let accumulatedThinking = "";
1532
1542
  let stderr = "";
1533
1543
  let stdoutRemainder = "";
1534
1544
  let capturedSessionId: string | undefined;
1535
1545
  let opencodeToolUse: string | undefined;
1546
+ let recoverableFailure: Error | undefined;
1536
1547
  // Text after the last blocked native call shows whether the model recovered in-run.
1537
1548
  let nativeTextMark = 0;
1538
1549
  let stepNativeCalls: (ParsedToolCall | undefined)[] = [];
@@ -1578,7 +1589,7 @@ export function streamOpenCode(
1578
1589
  dropOpencodeSession(piState);
1579
1590
  }
1580
1591
  const isContinuation = delta !== undefined;
1581
- let prompt = isContinuation
1592
+ prompt = isContinuation
1582
1593
  ? buildContinuationPrompt(delta)
1583
1594
  : buildPrompt(context);
1584
1595
  const sentMessages = isContinuation
@@ -1756,9 +1767,10 @@ export function streamOpenCode(
1756
1767
  }
1757
1768
  if (opencodeError) throw new Error(opencodeError);
1758
1769
  if (opencodeToolUse && !nativeCalls && !accumulatedText.slice(nativeTextMark).trim()) {
1759
- throw new Error(
1760
- `OpenCode attempted to use its disabled native tool (${JSON.stringify(opencodeToolUse)}). Retry the request; Pi tools must be requested with <pi_tool_call>{"name":"...","arguments":{}}</pi_tool_call> markers.`,
1770
+ recoverableFailure = new Error(
1771
+ `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.`,
1761
1772
  );
1773
+ throw recoverableFailure;
1762
1774
  }
1763
1775
 
1764
1776
  const toolCallResult = parseToolCallResponse(
@@ -1803,6 +1815,12 @@ export function streamOpenCode(
1803
1815
  );
1804
1816
  }
1805
1817
  }
1818
+ if (toolCalls.length === 0 && !displayText.trim() && accumulatedThinking.trim()) {
1819
+ recoverableFailure = new Error(
1820
+ `OpenCode returned only reasoning without an answer or Pi tool call${allowRecovery ? "" : " after one recovery attempt"}. Retry the request or select another OpenCode model.`,
1821
+ );
1822
+ throw recoverableFailure;
1823
+ }
1806
1824
  if (
1807
1825
  toolCalls.length === 0 &&
1808
1826
  !displayText.trim() &&
@@ -1930,6 +1948,38 @@ export function streamOpenCode(
1930
1948
  // deleted best-effort and never fails the turn (#71, #75).
1931
1949
  deleteOpenCodeSession(capturedSessionId);
1932
1950
  if (piState) dropOpencodeSession(piState);
1951
+ const remainingMs = deadline - Date.now();
1952
+ if (recoverableFailure && error === recoverableFailure && allowRecovery &&
1953
+ !options?.signal?.aborted && remainingMs > 0 && (!opencodeToolUse || context.tools?.length)) {
1954
+ setEstimatedUsage(model, output, prompt, accumulatedText + accumulatedThinking);
1955
+ const correction = [
1956
+ opencodeToolUse
1957
+ ? `Your previous attempt used the disabled OpenCode native tool ${JSON.stringify(opencodeToolUse)}.`
1958
+ : "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.",
1959
+ `Available Pi tools: ${context.tools?.map(tool => tool.name).join(", ") || "none"}.`,
1960
+ "Complete the original request using only those Pi tools. Do not call any OpenCode native tool.",
1961
+ 'Request Pi tools as <pi_tool_call>{"name":"tool_name","arguments":{}}</pi_tool_call> markers, or answer normally if no tool is needed.',
1962
+ "If Pi has no grep tool, request a search through Pi bash when it is available instead.",
1963
+ ].join("\n");
1964
+ // A fresh attempt keeps rejected native calls out of the continued transcript.
1965
+ const recovery = streamOpenCodeAttempt(model, {
1966
+ ...context,
1967
+ messages: [...context.messages, { role: "user", content: correction, timestamp: Date.now() }],
1968
+ }, { ...options, timeoutMs: remainingMs }, false);
1969
+ for await (const event of recovery) {
1970
+ if (event.type === "start") continue;
1971
+ const message = event.type === "done" ? event.message : event.type === "error" ? event.error : undefined;
1972
+ if (message) {
1973
+ for (const key of ["input", "output", "cacheRead", "cacheWrite", "totalTokens"] as const) {
1974
+ message.usage[key] += output.usage[key];
1975
+ }
1976
+ calculateCost(model, message.usage);
1977
+ }
1978
+ stream.push(event);
1979
+ }
1980
+ stream.end();
1981
+ return;
1982
+ }
1933
1983
  output.stopReason = options?.signal?.aborted ? "aborted" : "error";
1934
1984
  output.errorMessage =
1935
1985
  error instanceof Error ? error.message : String(error);
@@ -2053,9 +2103,9 @@ export default async function opencodePiExtension(pi: ExtensionAPI) {
2053
2103
  "info",
2054
2104
  );
2055
2105
  const version = /\d+\.\d+\.\d+/.exec(cliStatus.summary)?.[0];
2056
- if (!version || !VERIFIED_OPENCODE_VERSIONS.includes(version)) {
2106
+ if (!version || version.localeCompare(MAX_SUPPORTED_OPENCODE_VERSION, undefined, { numeric: true }) > 0) {
2057
2107
  ctx.ui.notify(
2058
- `opencode-pi: OpenCode ${version ?? cliStatus.summary} is not one of ${VERIFIED_OPENCODE_VERSIONS.join(", ")}, the versions the native-tool guard was verified with. Native OpenCode tools may not stay blocked.`,
2108
+ `opencode-pi: detected OpenCode ${version ?? cliStatus.summary}. This bridge supports OpenCode up to ${MAX_SUPPORTED_OPENCODE_VERSION}. Please install a supported version.`,
2059
2109
  "warning",
2060
2110
  );
2061
2111
  }