pi-opencode-cli-bridge 0.1.0 → 0.2.1

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. 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.
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.1",
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;
@@ -98,6 +100,7 @@ export type ToolCallParseResult =
98
100
  rejection:
99
101
  | { reason: "malformed_markers" }
100
102
  | { reason: "invalid_payload" }
103
+ | { reason: "incomplete_dsml" }
101
104
  | { reason: "unavailable_tool"; toolName: string };
102
105
  };
103
106
 
@@ -608,6 +611,78 @@ function objectRecord(value: unknown): Record<string, unknown> | undefined {
608
611
  : undefined;
609
612
  }
610
613
 
614
+ /**
615
+ * Map a blocked native OpenCode call onto the equivalent Pi tool, so Pi runs
616
+ * it under its own permissions. Calls whose semantics differ stay untranslated.
617
+ */
618
+ export function translateNativeToolCall(
619
+ tool: string,
620
+ input: unknown,
621
+ allowedToolNames: Set<string>,
622
+ projectDirs: string[],
623
+ paths: typeof posix = process.platform === "win32" ? win32 : posix,
624
+ ): ParsedToolCall | undefined {
625
+ const args = objectRecord(input);
626
+ if (!args) return undefined;
627
+ const text = (key: string) => typeof args[key] === "string" ? args[key] as string : undefined;
628
+ const count = (key: string) =>
629
+ typeof args[key] === "number" && args[key] > 0 ? args[key] as number : undefined;
630
+ // OpenCode resolves paths in its temporary project; Pi resolves them in the real one.
631
+ // Platform path rules also cover Windows slash and drive-letter case differences.
632
+ const path = (key: string) => {
633
+ const value = text(key);
634
+ if (value === undefined || !paths.isAbsolute(value)) return value;
635
+ for (const dir of projectDirs) {
636
+ const rel = paths.relative(dir, value);
637
+ if (rel !== ".." && !rel.startsWith(".." + paths.sep) && !paths.isAbsolute(rel)) return rel || ".";
638
+ }
639
+ return value;
640
+ };
641
+ const call = (name: string, entries: Record<string, unknown>): ParsedToolCall => ({
642
+ name,
643
+ arguments: Object.fromEntries(Object.entries(entries).filter(([, v]) => v !== undefined)),
644
+ });
645
+ let result: ParsedToolCall | undefined;
646
+ switch (tool) {
647
+ case "bash": {
648
+ // shortcut: absolute temp-project paths inside the command are not rewritten; parse them if models rely on it.
649
+ const timeout = count("timeout");
650
+ if (text("command") === undefined || (args.workdir !== undefined && path("workdir") !== ".")) break;
651
+ result = call("bash", { command: text("command"), timeout: timeout && Math.ceil(timeout / 1000) });
652
+ break;
653
+ }
654
+ case "read":
655
+ if (text("filePath") !== undefined) {
656
+ result = call("read", { path: path("filePath"), offset: count("offset"), limit: count("limit") });
657
+ }
658
+ break;
659
+ case "write":
660
+ if (text("filePath") !== undefined && text("content") !== undefined) {
661
+ result = call("write", { path: path("filePath"), content: text("content") });
662
+ }
663
+ break;
664
+ case "edit":
665
+ // Pi edits need one unique, non-empty match: no replaceAll or create-by-edit.
666
+ if (text("filePath") !== undefined && text("oldString") && text("newString") !== undefined &&
667
+ args.replaceAll !== true) {
668
+ result = call("edit", {
669
+ path: path("filePath"),
670
+ edits: [{ oldText: text("oldString"), newText: text("newString") }],
671
+ });
672
+ }
673
+ break;
674
+ case "grep":
675
+ if (text("pattern") !== undefined) {
676
+ result = call("grep", { pattern: text("pattern"), path: path("path"), glob: text("include") });
677
+ }
678
+ break;
679
+ case "glob":
680
+ if (text("pattern") !== undefined) result = call("find", { pattern: text("pattern"), path: path("path") });
681
+ break;
682
+ }
683
+ return result && allowedToolNames.has(result.name) ? result : undefined;
684
+ }
685
+
611
686
  function safeJson(value: unknown): string {
612
687
  try {
613
688
  return JSON.stringify(value, null, 2);
@@ -874,11 +949,70 @@ function stripMarkerSpans(text: string, spans: MarkerSpan[]): string {
874
949
  return cleaned;
875
950
  }
876
951
 
952
+ // DeepSeek sometimes ignores the markers and writes its native DSML tool-call
953
+ // syntax as plain text. Each complete block is rewritten in place as markers,
954
+ // so call order and the tool-list check stay those of the marker parser.
955
+ const DSML = String.raw`[||]+\s*DSML\s*[||]+`;
956
+ const DSML_TAG = new RegExp(String.raw`<\/?${DSML}`);
957
+ const DSML_BLOCK = new RegExp(String.raw`^<${DSML}(?:tool|function)_calls>([\s\S]*?)<\/${DSML}(?:tool|function)_calls>`);
958
+ const DSML_INVOKE = new RegExp(String.raw`<${DSML}invoke\s+name="([^"]+)"\s*>([\s\S]*?)<\/${DSML}invoke>`, "g");
959
+ const DSML_PARAMETER = new RegExp(String.raw`<${DSML}parameter\s+name="([^"]+)"(?:\s+string="(true|false)")?\s*>([\s\S]*?)<\/${DSML}parameter>`, "g");
960
+
961
+ function dsmlBlockToMarkers(body: string): string {
962
+ let markers = "";
963
+ for (const [, name, params] of body.matchAll(DSML_INVOKE)) {
964
+ const args: Record<string, unknown> = {};
965
+ for (const [, key, isString, raw] of params.matchAll(DSML_PARAMETER)) {
966
+ if (isString === "true") {
967
+ args[key] = raw;
968
+ continue;
969
+ }
970
+ try {
971
+ args[key] = JSON.parse(raw);
972
+ } catch {
973
+ args[key] = raw;
974
+ }
975
+ }
976
+ markers += `${MARKER_OPEN}${JSON.stringify({ name, arguments: args })}</pi_tool_call>`;
977
+ }
978
+ return markers;
979
+ }
980
+
981
+ // Scans left to right and consumes whichever starts first, a marker or a DSML
982
+ // block, so each keeps the other's syntax intact inside its own arguments.
983
+ // Returns undefined for a truncated DSML block, which would otherwise end the
984
+ // turn silently or let Pi run only the calls before it.
985
+ function dsmlToMarkers(text: string): string | undefined {
986
+ let result = "";
987
+ let rest = text;
988
+ for (;;) {
989
+ const dsmlAt = rest.search(DSML_TAG);
990
+ const markerAt = rest.indexOf(MARKER_OPEN);
991
+ if (dsmlAt < 0) return result + rest;
992
+ if (markerAt >= 0 && markerAt < dsmlAt) {
993
+ // The slice starts at the marker, so trimming cannot shift its span.
994
+ const extraction = toolCallMarkers(rest.slice(markerAt));
995
+ const span = [...(extraction?.markers ?? []), ...(extraction?.invalidSpans ?? [])].find((s) => s.start === 0);
996
+ const end = markerAt + (span?.end ?? MARKER_OPEN.length);
997
+ result += rest.slice(0, end);
998
+ rest = rest.slice(end);
999
+ continue;
1000
+ }
1001
+ const block = DSML_BLOCK.exec(rest.slice(dsmlAt));
1002
+ if (!block) return undefined;
1003
+ result += rest.slice(0, dsmlAt) + dsmlBlockToMarkers(block[1]);
1004
+ rest = rest.slice(dsmlAt + block[0].length);
1005
+ }
1006
+ }
1007
+
877
1008
  export function parseToolCallResponse(
878
1009
  text: string,
879
1010
  allowedToolNames?: ReadonlySet<string>,
880
1011
  ): ToolCallParseResult {
881
- const normalizedText = normalizeQuotedToolCallResponse(text);
1012
+ const normalizedText = dsmlToMarkers(normalizeQuotedToolCallResponse(text));
1013
+ if (normalizedText === undefined) {
1014
+ return { ok: false, rejection: { reason: "incomplete_dsml" } };
1015
+ }
882
1016
  if (!isToolCallMarkerResponse(normalizedText)) return { ok: true, calls: [] };
883
1017
 
884
1018
  const extraction = toolCallMarkers(normalizedText);
@@ -958,6 +1092,8 @@ function toolCallRejectionMessage(
958
1092
  return "OpenCode returned malformed Pi tool-call markers. Ensure each marker contains valid JSON with a string \"name\" and object \"arguments\".";
959
1093
  case "invalid_payload":
960
1094
  return 'OpenCode returned an invalid Pi tool-call payload. Each marker must contain valid JSON with a non-empty string "name" and object "arguments".';
1095
+ case "incomplete_dsml":
1096
+ return 'OpenCode returned an incomplete DeepSeek DSML tool call. Pi tools must be requested with <pi_tool_call>{"name":"...","arguments":{}}</pi_tool_call> markers.';
961
1097
  case "unavailable_tool": {
962
1098
  const available = [...allowedToolNames].map((name) =>
963
1099
  JSON.stringify(name),
@@ -1296,7 +1432,7 @@ You are the OpenCode side of a Pi Coding Agent bridge. Do not use OpenCode nativ
1296
1432
  join(pluginsDir, "pi-native-tool-blocker.js"),
1297
1433
  `export const PiNativeToolBlocker = async () => ({
1298
1434
  "tool.execute.before": async (input) => {
1299
- throw new Error("Native OpenCode tool blocked by opencode-pi: " + input.tool)
1435
+ 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
1436
  },
1301
1437
  })
1302
1438
  `,
@@ -1331,6 +1467,12 @@ export function streamOpenCode(
1331
1467
  let stdoutRemainder = "";
1332
1468
  let capturedSessionId: string | undefined;
1333
1469
  let opencodeToolUse: string | undefined;
1470
+ // Text after the last blocked native call shows whether the model recovered in-run.
1471
+ let nativeTextMark = 0;
1472
+ let stepNativeCalls: (ParsedToolCall | undefined)[] = [];
1473
+ let nativeCalls: ParsedToolCall[] | undefined;
1474
+ // Thrown from the event handler so the process runner stops OpenCode.
1475
+ const nativeStepEnd = new Error("OpenCode stopped after a translated native step");
1334
1476
  let opencodeError: string | undefined;
1335
1477
  let finishReason: "stop" | "length" = "stop";
1336
1478
  // Per-Pi-session reuse key supplied by Pi core (stable for the life of
@@ -1351,6 +1493,9 @@ export function streamOpenCode(
1351
1493
  tools: getCurrentTools(messages),
1352
1494
  };
1353
1495
  const transcriptParts = serializedTranscriptParts(context.messages);
1496
+ const allowedToolNames = new Set(
1497
+ context.tools?.map((tool) => tool.name) ?? [],
1498
+ );
1354
1499
  stream.push({ type: "start", partial: output });
1355
1500
  if (options?.signal?.aborted) throw new Error("Request was aborted");
1356
1501
 
@@ -1381,6 +1526,7 @@ export function streamOpenCode(
1381
1526
  return dir;
1382
1527
  });
1383
1528
  if (options?.signal?.aborted) throw new Error("Request was aborted");
1529
+ const projectDirs = [sessionDir, await realpath(sessionDir).catch(() => sessionDir)];
1384
1530
  const images = imageContentsForModel(
1385
1531
  sentMessages,
1386
1532
  model.input.includes("image"),
@@ -1410,7 +1556,8 @@ export function streamOpenCode(
1410
1556
  for (const path of imagePaths) args.push("--file", path);
1411
1557
 
1412
1558
  const handleLine = async (line: string) => {
1413
- if (!line.trim()) return;
1559
+ // Output after a translated native step belongs to a run Pi already ended.
1560
+ if (!line.trim() || nativeCalls) return;
1414
1561
  let parsed: unknown;
1415
1562
  try {
1416
1563
  parsed = JSON.parse(line);
@@ -1449,26 +1596,42 @@ export function streamOpenCode(
1449
1596
  finishReason = "length";
1450
1597
  }
1451
1598
  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);
1599
+ if (tokens) {
1600
+ const cache = objectRecord(tokens.cache);
1601
+ output.usage.input = Number(tokens.input ?? 0);
1602
+ output.usage.output =
1603
+ Number(tokens.output ?? 0) + Number(tokens.reasoning ?? 0);
1604
+ output.usage.cacheRead = Number(cache?.read ?? 0);
1605
+ output.usage.cacheWrite = Number(cache?.write ?? 0);
1606
+ output.usage.totalTokens = Number(
1607
+ tokens.total ??
1608
+ output.usage.input +
1609
+ output.usage.output +
1610
+ output.usage.cacheRead +
1611
+ output.usage.cacheWrite,
1612
+ );
1613
+ calculateCost(model, output.usage);
1614
+ }
1615
+ const calls = stepNativeCalls;
1616
+ stepNativeCalls = [];
1617
+ // A fully translatable step ends the turn like a normal tool-call response.
1618
+ // Otherwise OpenCode continues and the model sees the blocker's correction.
1619
+ if (calls.length > 0 && calls.every(Boolean)) {
1620
+ nativeCalls = calls as ParsedToolCall[];
1621
+ throw nativeStepEnd;
1622
+ }
1467
1623
  return;
1468
1624
  }
1469
1625
 
1470
1626
  if (event.type === "tool_use") {
1471
1627
  opencodeToolUse = part?.tool ? String(part.tool) : "unknown";
1628
+ nativeTextMark = accumulatedText.length;
1629
+ stepNativeCalls.push(translateNativeToolCall(
1630
+ opencodeToolUse,
1631
+ objectRecord(part?.state)?.input,
1632
+ allowedToolNames,
1633
+ projectDirs,
1634
+ ));
1472
1635
  return;
1473
1636
  }
1474
1637
 
@@ -1514,22 +1677,24 @@ export function streamOpenCode(
1514
1677
  for (const line of lines) await handleLine(line);
1515
1678
  },
1516
1679
  onEnd: () => handleLine(stdoutRemainder),
1680
+ }).catch((error: unknown) => {
1681
+ // Stopping OpenCode after a translated native step is a normal end of turn.
1682
+ if (error === nativeStepEnd) return undefined;
1683
+ throw error;
1517
1684
  });
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}`);
1685
+ if (result) {
1686
+ stderr = (stderr + result.stderr).slice(-STDERR_LIMIT);
1687
+ if (result.code !== 0) {
1688
+ throw new Error(stderr.trim() || `opencode exited with code ${result.code}`);
1689
+ }
1522
1690
  }
1523
1691
  if (opencodeError) throw new Error(opencodeError);
1524
- if (opencodeToolUse) {
1692
+ if (opencodeToolUse && !nativeCalls && !accumulatedText.slice(nativeTextMark).trim()) {
1525
1693
  throw new Error(
1526
1694
  `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
1695
  );
1528
1696
  }
1529
1697
 
1530
- const allowedToolNames = new Set(
1531
- context.tools?.map((tool) => tool.name) ?? [],
1532
- );
1533
1698
  const toolCallResult = parseToolCallResponse(
1534
1699
  accumulatedText,
1535
1700
  allowedToolNames,
@@ -1539,7 +1704,7 @@ export function streamOpenCode(
1539
1704
  toolCallRejectionMessage(toolCallResult, allowedToolNames),
1540
1705
  );
1541
1706
  }
1542
- const toolCalls = toolCallResult.calls;
1707
+ const toolCalls = [...toolCallResult.calls, ...(nativeCalls ?? [])];
1543
1708
  // Unrecoverable marker text is stripped so it never leaks into the
1544
1709
  // visible chat response; remaining prose still displays normally.
1545
1710
  const displayText = toolCallResult.cleanedText ?? accumulatedText;
@@ -1585,7 +1750,8 @@ export function streamOpenCode(
1585
1750
  );
1586
1751
  // The turn succeeded: record the session for reuse, or delete the
1587
1752
  // one-shot session record so it cannot accumulate (#71, #75).
1588
- if (piState && !payloadHook) {
1753
+ // A session holding a blocked native call cannot be continued as a transcript prefix.
1754
+ if (piState && !payloadHook && !nativeCalls) {
1589
1755
  piState.opencodeSessionId =
1590
1756
  capturedSessionId ?? piState.opencodeSessionId;
1591
1757
  piState.modelId = model.id;
@@ -1593,6 +1759,7 @@ export function streamOpenCode(
1593
1759
  piState.systemPrompt = context.systemPrompt ?? "";
1594
1760
  piState.sentParts = transcriptParts;
1595
1761
  } else {
1762
+ if (piState) dropOpencodeSession(piState);
1596
1763
  deleteOpenCodeSession(capturedSessionId);
1597
1764
  }
1598
1765
 
@@ -1800,6 +1967,13 @@ export default async function opencodePiExtension(pi: ExtensionAPI) {
1800
1967
  `opencode-pi: registered ${registeredModels.length} OpenCode CLI model(s). Use /model and pick ${PROVIDER_ID}.`,
1801
1968
  "info",
1802
1969
  );
1970
+ const version = /\d+\.\d+\.\d+/.exec(cliStatus.summary)?.[0];
1971
+ if (version !== VERIFIED_OPENCODE_VERSION) {
1972
+ ctx.ui.notify(
1973
+ `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.`,
1974
+ "warning",
1975
+ );
1976
+ }
1803
1977
  if (lastDiscoveryError) {
1804
1978
  ctx.ui.notify(
1805
1979
  `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) => {