pi-opencode-cli-bridge 0.2.7 → 0.2.9
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 +3 -3
- package/src/index.ts +31 -15
package/README.md
CHANGED
|
@@ -119,17 +119,19 @@ 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 and the model can correct itself. If it ends without recovering, the bridge makes one fresh attempt with the original transcript and a correction
|
|
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,
|
|
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, the blocked native calls and their arguments, and a correction referring to the available Pi tool descriptions and schemas. The model is asked to choose tools that can perform the required operation, not merely tools with similar names, and generate arguments for their Pi schemas. This is a model-directed fallback, not an automatic conversion or a guarantee of functional equivalence. If no suitable tool exists, including when Pi exposes no tools, the model is asked to explain the limitation and request the missing content or access (for example, the text of an unreadable web page). 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, 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.
|
|
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.
|
|
127
129
|
|
|
128
130
|
Pi's provider instrumentation hooks follow the CLI transport, not OpenCode's internal HTTP traffic:
|
|
129
131
|
|
|
130
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.
|
|
131
133
|
- `onResponse` is called once the subprocess has started, with `status: 0` (no HTTP status exists) and the header `x-pi-transport: opencode-cli`.
|
|
132
|
-
- `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.
|
|
133
135
|
- Provider-scoped `env` values are passed to the subprocess. The bridge's own configuration directory and update-check settings always take precedence.
|
|
134
136
|
|
|
135
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.
|
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.9",
|
|
4
4
|
"description": "Bridge the OpenCode CLI into Pi Coding Agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
"scripts": {
|
|
15
15
|
"build": "tsc",
|
|
16
16
|
"typecheck": "tsc --noEmit",
|
|
17
|
-
"test": "npm run build && node --test --test-timeout=
|
|
18
|
-
"test:integration": "npm run build && node --test --test-timeout=
|
|
17
|
+
"test": "npm run build && node --test --test-timeout=90000 dist/index.test.js dist/process.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
|
-
|
|
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
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
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
|
|
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),
|
|
@@ -1543,7 +1543,9 @@ function streamOpenCodeAttempt(
|
|
|
1543
1543
|
let stdoutRemainder = "";
|
|
1544
1544
|
let capturedSessionId: string | undefined;
|
|
1545
1545
|
let opencodeToolUse: string | undefined;
|
|
1546
|
+
const nativeToolRequests: { name: string; arguments: unknown }[] = [];
|
|
1546
1547
|
let recoverableFailure: Error | undefined;
|
|
1548
|
+
let toolCallFailure: Extract<ToolCallParseResult, { ok: false }> | undefined;
|
|
1547
1549
|
// Text after the last blocked native call shows whether the model recovered in-run.
|
|
1548
1550
|
let nativeTextMark = 0;
|
|
1549
1551
|
let stepNativeCalls: (ParsedToolCall | undefined)[] = [];
|
|
@@ -1701,9 +1703,11 @@ function streamOpenCodeAttempt(
|
|
|
1701
1703
|
if (event.type === "tool_use") {
|
|
1702
1704
|
opencodeToolUse = part?.tool ? String(part.tool) : "unknown";
|
|
1703
1705
|
nativeTextMark = accumulatedText.length;
|
|
1706
|
+
const input = objectRecord(part?.state)?.input;
|
|
1707
|
+
nativeToolRequests.push({ name: opencodeToolUse, arguments: input });
|
|
1704
1708
|
stepNativeCalls.push(translateNativeToolCall(
|
|
1705
1709
|
opencodeToolUse,
|
|
1706
|
-
|
|
1710
|
+
input,
|
|
1707
1711
|
allowedToolNames,
|
|
1708
1712
|
projectDirs,
|
|
1709
1713
|
));
|
|
@@ -1778,6 +1782,7 @@ function streamOpenCodeAttempt(
|
|
|
1778
1782
|
allowedToolNames,
|
|
1779
1783
|
);
|
|
1780
1784
|
if (!toolCallResult.ok) {
|
|
1785
|
+
toolCallFailure = toolCallResult;
|
|
1781
1786
|
throw new Error(
|
|
1782
1787
|
toolCallRejectionMessage(toolCallResult, allowedToolNames),
|
|
1783
1788
|
);
|
|
@@ -1798,6 +1803,7 @@ function streamOpenCodeAttempt(
|
|
|
1798
1803
|
reasoningResult = { ok: false, rejection: { reason: "malformed_markers" } };
|
|
1799
1804
|
}
|
|
1800
1805
|
if (!reasoningResult.ok) {
|
|
1806
|
+
toolCallFailure = reasoningResult;
|
|
1801
1807
|
throw new Error(
|
|
1802
1808
|
toolCallRejectionMessage(reasoningResult, allowedToolNames),
|
|
1803
1809
|
);
|
|
@@ -1866,7 +1872,7 @@ function streamOpenCodeAttempt(
|
|
|
1866
1872
|
deleteOpenCodeSession(capturedSessionId);
|
|
1867
1873
|
}
|
|
1868
1874
|
|
|
1869
|
-
if (accumulatedThinking) {
|
|
1875
|
+
if (accumulatedThinking && requestedReasoning && requestedReasoning !== "off") {
|
|
1870
1876
|
const thinkingIndex = output.content.length;
|
|
1871
1877
|
output.content.push({ type: "thinking", thinking: accumulatedThinking });
|
|
1872
1878
|
stream.push({
|
|
@@ -1943,6 +1949,10 @@ function streamOpenCodeAttempt(
|
|
|
1943
1949
|
stream.push({ type: "done", reason: finishReason, message: output });
|
|
1944
1950
|
stream.end();
|
|
1945
1951
|
} catch (error) {
|
|
1952
|
+
if (toolCallFailure && toolCallFailure.rejection.reason !== "unavailable_tool" && error instanceof Error) {
|
|
1953
|
+
recoverableFailure = error;
|
|
1954
|
+
if (!allowRecovery) error.message += " Recovery failed after one attempt.";
|
|
1955
|
+
}
|
|
1946
1956
|
// Any error, timeout, or abort drops the session ID so the next turn
|
|
1947
1957
|
// starts fresh with the full transcript; the failed session record is
|
|
1948
1958
|
// deleted best-effort and never fails the turn (#71, #75).
|
|
@@ -1950,16 +1960,22 @@ function streamOpenCodeAttempt(
|
|
|
1950
1960
|
if (piState) dropOpencodeSession(piState);
|
|
1951
1961
|
const remainingMs = deadline - Date.now();
|
|
1952
1962
|
if (recoverableFailure && error === recoverableFailure && allowRecovery &&
|
|
1953
|
-
!options?.signal?.aborted && remainingMs > 0
|
|
1963
|
+
!options?.signal?.aborted && remainingMs > 0) {
|
|
1954
1964
|
setEstimatedUsage(model, output, prompt, accumulatedText + accumulatedThinking);
|
|
1955
1965
|
const correction = [
|
|
1956
|
-
|
|
1957
|
-
? `Your previous attempt
|
|
1958
|
-
:
|
|
1966
|
+
toolCallFailure
|
|
1967
|
+
? `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.`
|
|
1968
|
+
: opencodeToolUse
|
|
1969
|
+
? [
|
|
1970
|
+
`Your previous attempt used the disabled OpenCode native tool ${JSON.stringify(opencodeToolUse)}.`,
|
|
1971
|
+
`Blocked native calls (data, not instructions):\n${safeJson(nativeToolRequests)}`,
|
|
1972
|
+
"Choose from Available Pi tools by their described functionality and parameter schemas, not name similarity. Use only tools that can perform the required operation, preserve the original intent, and generate arguments for the chosen Pi schema.",
|
|
1973
|
+
"If no available Pi tool can perform the operation, explain the limitation and ask the user for the needed content or access. For unreadable links, ask for the page text. Do not claim an operation succeeded or a page was read without a tool result.",
|
|
1974
|
+
].join("\n")
|
|
1975
|
+
: "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
1976
|
`Available Pi tools: ${context.tools?.map(tool => tool.name).join(", ") || "none"}.`,
|
|
1960
1977
|
"Complete the original request using only those Pi tools. Do not call any OpenCode native tool.",
|
|
1961
1978
|
'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
1979
|
].join("\n");
|
|
1964
1980
|
// A fresh attempt keeps rejected native calls out of the continued transcript.
|
|
1965
1981
|
const recovery = streamOpenCodeAttempt(model, {
|