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 +6 -4
- package/package.json +2 -2
- package/src/index.ts +57 -7
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`.
|
|
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
|
|
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
|
|
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.
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1760
|
-
`OpenCode
|
|
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 ||
|
|
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}
|
|
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
|
}
|