pi-opencode-cli-bridge 0.1.0 → 0.2.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
@@ -15,6 +15,7 @@ This fork fixes that and closes the gaps found while reviewing the code:
15
15
  - **No hung turns or orphaned processes.** Timeouts and cancellation kill the OpenCode process group, even when it ignores `SIGTERM`.
16
16
  - **Unexpected CLI output no longer crashes Pi.** Malformed events end the turn with an error.
17
17
  - **Clean shutdown.** Pi waits for temporary directories and OpenCode session records to be removed.
18
+ - **Fewer failed turns when the model reaches for OpenCode's own tools.** A blocked native `bash`, `read`, `write`, `edit`, `grep` or `glob` call with a Pi equivalent is handed to Pi as a normal tool call. For other tools, the model is told how to ask Pi instead and can recover in the same run.
18
19
  - **Standard Pi provider hooks.** `onPayload`, `onResponse` and `onProviderStreamEvent` work as with built-in providers.
19
20
  - **Its own npm package**, with the original authors credited.
20
21
 
@@ -114,8 +115,9 @@ Without an override, discovery selects active models whose reported input and ou
114
115
  2. It replays Pi's transcript system messages to recover the current system prompt and tool declarations, including later changes.
115
116
  3. It creates a temporary OpenCode project containing a `pi-model` agent and a `tool.execute.before` plugin. Native tool declarations remain on the request. Execution is stopped twice: the plugin throws before any native tool runs, and the agent sets every permission to `ask`, which non-interactive `opencode run` rejects automatically. The second layer still holds if the plugin is missing or fails to load.
116
117
  4. It sends the prompt over standard input to `opencode run --format json`. User and tool-result images are written to temporary files and attached only when the model supports images.
117
- 5. 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.
118
- 6. 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.
118
+ 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`, `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.
119
+ 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.
120
+ 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.
119
121
 
120
122
  Pi's provider instrumentation hooks follow the CLI transport, not OpenCode's internal HTTP traffic:
121
123
 
@@ -134,7 +136,8 @@ Reasoning variants, image support and token limits follow CLI discovery. Token u
134
136
  - Pi and OpenCode can both retain local conversation data. Best-effort cleanup is not a guarantee of secure deletion.
135
137
  - 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.
136
138
  - 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.
137
- - Timeouts and cancellation escalate from `SIGTERM` to `SIGKILL` on the CLI's process group after 250 ms. Malformed or unexpected CLI events end the turn with an error instead of crashing Pi. Shutdown waits for session cleanup.
139
+ - At startup Pi warns if your OpenCode version is not 1.19.0, the version the native-tool guard was verified with.
140
+ - 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.
138
141
 
139
142
  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.
140
143
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-opencode-cli-bridge",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Bridge the OpenCode CLI into Pi Coding Agent",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -34,9 +34,18 @@
34
34
  ],
35
35
  "author": "pi-extensions contributors",
36
36
  "contributors": [
37
- { "name": "luongnv89", "url": "https://github.com/luongnv89" },
38
- { "name": "Rodrigo Manuel Jager Pinheiro", "url": "https://github.com/rodrigojager" },
39
- { "name": "Gybra", "url": "https://github.com/Gybra" }
37
+ {
38
+ "name": "luongnv89",
39
+ "url": "https://github.com/luongnv89"
40
+ },
41
+ {
42
+ "name": "Rodrigo Manuel Jager Pinheiro",
43
+ "url": "https://github.com/rodrigojager"
44
+ },
45
+ {
46
+ "name": "Gybra",
47
+ "url": "https://github.com/Gybra"
48
+ }
40
49
  ],
41
50
  "license": "MIT",
42
51
  "engines": {
package/src/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { runOpenCodeProcess } from "./process.js";
2
2
  import { existsSync } from "node:fs";
3
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
- import { dirname, join } from "node:path";
5
+ import { dirname, join, posix, win32 } from "node:path";
6
6
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
7
  import { registerApiProvider } from "@earendil-works/pi-ai/compat";
8
8
  import {
@@ -29,6 +29,8 @@ import {
29
29
  const PROVIDER_ID = "opencode-cli";
30
30
  const API_ID = "opencode-cli-runner";
31
31
  const AGENT_ID = "pi-model";
32
+ // The native-tool guard is verified against this CLI release by the integration test.
33
+ const VERIFIED_OPENCODE_VERSION = "1.19.0";
32
34
  const DEFAULT_CONTEXT_WINDOW = 128_000;
33
35
  const DEFAULT_MAX_TOKENS = 16_384;
34
36
  const DISCOVERY_TIMEOUT_MS = 8_000;
@@ -608,6 +610,78 @@ function objectRecord(value: unknown): Record<string, unknown> | undefined {
608
610
  : undefined;
609
611
  }
610
612
 
613
+ /**
614
+ * Map a blocked native OpenCode call onto the equivalent Pi tool, so Pi runs
615
+ * it under its own permissions. Calls whose semantics differ stay untranslated.
616
+ */
617
+ export function translateNativeToolCall(
618
+ tool: string,
619
+ input: unknown,
620
+ allowedToolNames: Set<string>,
621
+ projectDirs: string[],
622
+ paths: typeof posix = process.platform === "win32" ? win32 : posix,
623
+ ): ParsedToolCall | undefined {
624
+ const args = objectRecord(input);
625
+ if (!args) return undefined;
626
+ const text = (key: string) => typeof args[key] === "string" ? args[key] as string : undefined;
627
+ const count = (key: string) =>
628
+ typeof args[key] === "number" && args[key] > 0 ? args[key] as number : undefined;
629
+ // OpenCode resolves paths in its temporary project; Pi resolves them in the real one.
630
+ // Platform path rules also cover Windows slash and drive-letter case differences.
631
+ const path = (key: string) => {
632
+ const value = text(key);
633
+ if (value === undefined || !paths.isAbsolute(value)) return value;
634
+ for (const dir of projectDirs) {
635
+ const rel = paths.relative(dir, value);
636
+ if (rel !== ".." && !rel.startsWith(".." + paths.sep) && !paths.isAbsolute(rel)) return rel || ".";
637
+ }
638
+ return value;
639
+ };
640
+ const call = (name: string, entries: Record<string, unknown>): ParsedToolCall => ({
641
+ name,
642
+ arguments: Object.fromEntries(Object.entries(entries).filter(([, v]) => v !== undefined)),
643
+ });
644
+ let result: ParsedToolCall | undefined;
645
+ switch (tool) {
646
+ case "bash": {
647
+ // shortcut: absolute temp-project paths inside the command are not rewritten; parse them if models rely on it.
648
+ const timeout = count("timeout");
649
+ if (text("command") === undefined || (args.workdir !== undefined && path("workdir") !== ".")) break;
650
+ result = call("bash", { command: text("command"), timeout: timeout && Math.ceil(timeout / 1000) });
651
+ break;
652
+ }
653
+ case "read":
654
+ if (text("filePath") !== undefined) {
655
+ result = call("read", { path: path("filePath"), offset: count("offset"), limit: count("limit") });
656
+ }
657
+ break;
658
+ case "write":
659
+ if (text("filePath") !== undefined && text("content") !== undefined) {
660
+ result = call("write", { path: path("filePath"), content: text("content") });
661
+ }
662
+ break;
663
+ case "edit":
664
+ // Pi edits need one unique, non-empty match: no replaceAll or create-by-edit.
665
+ if (text("filePath") !== undefined && text("oldString") && text("newString") !== undefined &&
666
+ args.replaceAll !== true) {
667
+ result = call("edit", {
668
+ path: path("filePath"),
669
+ edits: [{ oldText: text("oldString"), newText: text("newString") }],
670
+ });
671
+ }
672
+ break;
673
+ case "grep":
674
+ if (text("pattern") !== undefined) {
675
+ result = call("grep", { pattern: text("pattern"), path: path("path"), glob: text("include") });
676
+ }
677
+ break;
678
+ case "glob":
679
+ if (text("pattern") !== undefined) result = call("find", { pattern: text("pattern"), path: path("path") });
680
+ break;
681
+ }
682
+ return result && allowedToolNames.has(result.name) ? result : undefined;
683
+ }
684
+
611
685
  function safeJson(value: unknown): string {
612
686
  try {
613
687
  return JSON.stringify(value, null, 2);
@@ -1296,7 +1370,7 @@ You are the OpenCode side of a Pi Coding Agent bridge. Do not use OpenCode nativ
1296
1370
  join(pluginsDir, "pi-native-tool-blocker.js"),
1297
1371
  `export const PiNativeToolBlocker = async () => ({
1298
1372
  "tool.execute.before": async (input) => {
1299
- throw new Error("Native OpenCode tool blocked by opencode-pi: " + input.tool)
1373
+ throw new Error("Native OpenCode tool " + input.tool + " is disabled in the Pi bridge. Request the equivalent Pi tool instead: reply with <pi_tool_call>{\\"name\\":\\"tool_name\\",\\"arguments\\":{}}</pi_tool_call> using a tool from the Pi tool list.")
1300
1374
  },
1301
1375
  })
1302
1376
  `,
@@ -1331,6 +1405,12 @@ export function streamOpenCode(
1331
1405
  let stdoutRemainder = "";
1332
1406
  let capturedSessionId: string | undefined;
1333
1407
  let opencodeToolUse: string | undefined;
1408
+ // Text after the last blocked native call shows whether the model recovered in-run.
1409
+ let nativeTextMark = 0;
1410
+ let stepNativeCalls: (ParsedToolCall | undefined)[] = [];
1411
+ let nativeCalls: ParsedToolCall[] | undefined;
1412
+ // Thrown from the event handler so the process runner stops OpenCode.
1413
+ const nativeStepEnd = new Error("OpenCode stopped after a translated native step");
1334
1414
  let opencodeError: string | undefined;
1335
1415
  let finishReason: "stop" | "length" = "stop";
1336
1416
  // Per-Pi-session reuse key supplied by Pi core (stable for the life of
@@ -1351,6 +1431,9 @@ export function streamOpenCode(
1351
1431
  tools: getCurrentTools(messages),
1352
1432
  };
1353
1433
  const transcriptParts = serializedTranscriptParts(context.messages);
1434
+ const allowedToolNames = new Set(
1435
+ context.tools?.map((tool) => tool.name) ?? [],
1436
+ );
1354
1437
  stream.push({ type: "start", partial: output });
1355
1438
  if (options?.signal?.aborted) throw new Error("Request was aborted");
1356
1439
 
@@ -1381,6 +1464,7 @@ export function streamOpenCode(
1381
1464
  return dir;
1382
1465
  });
1383
1466
  if (options?.signal?.aborted) throw new Error("Request was aborted");
1467
+ const projectDirs = [sessionDir, await realpath(sessionDir).catch(() => sessionDir)];
1384
1468
  const images = imageContentsForModel(
1385
1469
  sentMessages,
1386
1470
  model.input.includes("image"),
@@ -1410,7 +1494,8 @@ export function streamOpenCode(
1410
1494
  for (const path of imagePaths) args.push("--file", path);
1411
1495
 
1412
1496
  const handleLine = async (line: string) => {
1413
- if (!line.trim()) return;
1497
+ // Output after a translated native step belongs to a run Pi already ended.
1498
+ if (!line.trim() || nativeCalls) return;
1414
1499
  let parsed: unknown;
1415
1500
  try {
1416
1501
  parsed = JSON.parse(line);
@@ -1449,26 +1534,42 @@ export function streamOpenCode(
1449
1534
  finishReason = "length";
1450
1535
  }
1451
1536
  const tokens = objectRecord(part?.tokens);
1452
- if (!tokens) return;
1453
- const cache = objectRecord(tokens.cache);
1454
- output.usage.input = Number(tokens.input ?? 0);
1455
- output.usage.output =
1456
- Number(tokens.output ?? 0) + Number(tokens.reasoning ?? 0);
1457
- output.usage.cacheRead = Number(cache?.read ?? 0);
1458
- output.usage.cacheWrite = Number(cache?.write ?? 0);
1459
- output.usage.totalTokens = Number(
1460
- tokens.total ??
1461
- output.usage.input +
1462
- output.usage.output +
1463
- output.usage.cacheRead +
1464
- output.usage.cacheWrite,
1465
- );
1466
- calculateCost(model, output.usage);
1537
+ if (tokens) {
1538
+ const cache = objectRecord(tokens.cache);
1539
+ output.usage.input = Number(tokens.input ?? 0);
1540
+ output.usage.output =
1541
+ Number(tokens.output ?? 0) + Number(tokens.reasoning ?? 0);
1542
+ output.usage.cacheRead = Number(cache?.read ?? 0);
1543
+ output.usage.cacheWrite = Number(cache?.write ?? 0);
1544
+ output.usage.totalTokens = Number(
1545
+ tokens.total ??
1546
+ output.usage.input +
1547
+ output.usage.output +
1548
+ output.usage.cacheRead +
1549
+ output.usage.cacheWrite,
1550
+ );
1551
+ calculateCost(model, output.usage);
1552
+ }
1553
+ const calls = stepNativeCalls;
1554
+ stepNativeCalls = [];
1555
+ // A fully translatable step ends the turn like a normal tool-call response.
1556
+ // Otherwise OpenCode continues and the model sees the blocker's correction.
1557
+ if (calls.length > 0 && calls.every(Boolean)) {
1558
+ nativeCalls = calls as ParsedToolCall[];
1559
+ throw nativeStepEnd;
1560
+ }
1467
1561
  return;
1468
1562
  }
1469
1563
 
1470
1564
  if (event.type === "tool_use") {
1471
1565
  opencodeToolUse = part?.tool ? String(part.tool) : "unknown";
1566
+ nativeTextMark = accumulatedText.length;
1567
+ stepNativeCalls.push(translateNativeToolCall(
1568
+ opencodeToolUse,
1569
+ objectRecord(part?.state)?.input,
1570
+ allowedToolNames,
1571
+ projectDirs,
1572
+ ));
1472
1573
  return;
1473
1574
  }
1474
1575
 
@@ -1514,22 +1615,24 @@ export function streamOpenCode(
1514
1615
  for (const line of lines) await handleLine(line);
1515
1616
  },
1516
1617
  onEnd: () => handleLine(stdoutRemainder),
1618
+ }).catch((error: unknown) => {
1619
+ // Stopping OpenCode after a translated native step is a normal end of turn.
1620
+ if (error === nativeStepEnd) return undefined;
1621
+ throw error;
1517
1622
  });
1518
- const { code } = result;
1519
- stderr = (stderr + result.stderr).slice(-STDERR_LIMIT);
1520
- if (code !== 0) {
1521
- throw new Error(stderr.trim() || `opencode exited with code ${code}`);
1623
+ if (result) {
1624
+ stderr = (stderr + result.stderr).slice(-STDERR_LIMIT);
1625
+ if (result.code !== 0) {
1626
+ throw new Error(stderr.trim() || `opencode exited with code ${result.code}`);
1627
+ }
1522
1628
  }
1523
1629
  if (opencodeError) throw new Error(opencodeError);
1524
- if (opencodeToolUse) {
1630
+ if (opencodeToolUse && !nativeCalls && !accumulatedText.slice(nativeTextMark).trim()) {
1525
1631
  throw new Error(
1526
1632
  `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.`,
1527
1633
  );
1528
1634
  }
1529
1635
 
1530
- const allowedToolNames = new Set(
1531
- context.tools?.map((tool) => tool.name) ?? [],
1532
- );
1533
1636
  const toolCallResult = parseToolCallResponse(
1534
1637
  accumulatedText,
1535
1638
  allowedToolNames,
@@ -1539,7 +1642,7 @@ export function streamOpenCode(
1539
1642
  toolCallRejectionMessage(toolCallResult, allowedToolNames),
1540
1643
  );
1541
1644
  }
1542
- const toolCalls = toolCallResult.calls;
1645
+ const toolCalls = [...toolCallResult.calls, ...(nativeCalls ?? [])];
1543
1646
  // Unrecoverable marker text is stripped so it never leaks into the
1544
1647
  // visible chat response; remaining prose still displays normally.
1545
1648
  const displayText = toolCallResult.cleanedText ?? accumulatedText;
@@ -1585,7 +1688,8 @@ export function streamOpenCode(
1585
1688
  );
1586
1689
  // The turn succeeded: record the session for reuse, or delete the
1587
1690
  // one-shot session record so it cannot accumulate (#71, #75).
1588
- if (piState && !payloadHook) {
1691
+ // A session holding a blocked native call cannot be continued as a transcript prefix.
1692
+ if (piState && !payloadHook && !nativeCalls) {
1589
1693
  piState.opencodeSessionId =
1590
1694
  capturedSessionId ?? piState.opencodeSessionId;
1591
1695
  piState.modelId = model.id;
@@ -1593,6 +1697,7 @@ export function streamOpenCode(
1593
1697
  piState.systemPrompt = context.systemPrompt ?? "";
1594
1698
  piState.sentParts = transcriptParts;
1595
1699
  } else {
1700
+ if (piState) dropOpencodeSession(piState);
1596
1701
  deleteOpenCodeSession(capturedSessionId);
1597
1702
  }
1598
1703
 
@@ -1800,6 +1905,13 @@ export default async function opencodePiExtension(pi: ExtensionAPI) {
1800
1905
  `opencode-pi: registered ${registeredModels.length} OpenCode CLI model(s). Use /model and pick ${PROVIDER_ID}.`,
1801
1906
  "info",
1802
1907
  );
1908
+ const version = /\d+\.\d+\.\d+/.exec(cliStatus.summary)?.[0];
1909
+ if (version !== VERIFIED_OPENCODE_VERSION) {
1910
+ ctx.ui.notify(
1911
+ `opencode-pi: OpenCode ${version ?? cliStatus.summary} is not ${VERIFIED_OPENCODE_VERSION}, the version the native-tool guard was verified with. Native OpenCode tools may not stay blocked.`,
1912
+ "warning",
1913
+ );
1914
+ }
1803
1915
  if (lastDiscoveryError) {
1804
1916
  ctx.ui.notify(
1805
1917
  `opencode-pi: model discovery used fallback (${lastDiscoveryError})`,
package/src/process.ts CHANGED
@@ -33,8 +33,11 @@ export async function runOpenCodeProcess(
33
33
  const signalChild = (signal: NodeJS.Signals) => {
34
34
  if (!child.pid) return;
35
35
  try {
36
- if (process.platform === "win32") child.kill(signal);
37
- else process.kill(-child.pid, signal);
36
+ if (process.platform === "win32") {
37
+ // child.kill would stop only the launcher; /T also ends the native CLI it spawned.
38
+ spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true })
39
+ .on("error", () => child.kill(signal));
40
+ } else process.kill(-child.pid, signal);
38
41
  } catch { /* The child may already have exited. */ }
39
42
  };
40
43
  const stop = (error: Error) => {
@@ -59,7 +62,10 @@ export async function runOpenCodeProcess(
59
62
  });
60
63
  // Observe spawn failures immediately, even while a start callback is awaiting.
61
64
  void closed.catch(() => undefined);
62
- child.stdin?.on("error", stop);
65
+ // A CLI that quits before reading its prompt explains why via stderr and exit code.
66
+ child.stdin?.on("error", (error: NodeJS.ErrnoException) => {
67
+ if (error.code !== "EPIPE") stop(error);
68
+ });
63
69
  child.stdout.setEncoding("utf8");
64
70
  child.stderr.setEncoding("utf8");
65
71
  child.stderr.on("data", (chunk: string) => {