pi-opencode-cli-bridge 0.2.4 → 0.2.6

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,12 +33,16 @@ 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; other versions, including 2.x, 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
 
40
40
  ```sh
41
41
  opencode --version
42
+ opencode models
43
+ # Metadata on OpenCode 2.x:
44
+ opencode api --standalone get /api/model
45
+ # Metadata on OpenCode 1.x:
42
46
  opencode models opencode --verbose
43
47
  ```
44
48
 
@@ -107,15 +111,15 @@ The `test` command prints diagnostic commands; it does not send a model request
107
111
 
108
112
  An explicit model list skips startup discovery and starts with conservative fallback capabilities. Run `/opencode-pi update` to discover image input, reasoning variants, token limits and costs.
109
113
 
110
- Without an override, discovery selects active models whose reported input and output costs are zero. When cost metadata is missing, selection falls back to the upstream free-model naming rules. Review costs before explicitly selecting a paid model.
114
+ Without an override, discovery selects active `opencode/` models whose reported input and output costs are zero. OpenCode 2.x discovery uses the local `/api/model` endpoint, excludes disabled models, and checks every pricing tier before selecting a free model. For explicitly configured tiered models, displayed costs conservatively use the highest reported rate for each cost field. When cost metadata is missing, selection falls back to the upstream free-model naming rules. Review costs before explicitly selecting a paid model.
111
115
 
112
116
  ## How it works
113
117
 
114
118
  1. The extension discovers models through the local CLI.
115
119
  2. It replays Pi's transcript system messages to recover the current system prompt and tool declarations, including later changes.
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.
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.
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.
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
+ 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 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.
119
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.
120
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.
121
125
 
@@ -126,17 +130,17 @@ Pi's provider instrumentation hooks follow the CLI transport, not OpenCode's int
126
130
  - `onProviderStreamEvent` receives each validated `--format json` event in order, and is awaited before the event is processed.
127
131
  - Provider-scoped `env` values are passed to the subprocess. The bridge's own configuration directory and update-check settings always take precedence.
128
132
 
129
- 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.
133
+ 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 native-tool recovery is needed. Session records and temporary project directories are cleaned up on a best-effort basis.
130
134
 
131
135
  ## Security, privacy and limits
132
136
 
133
137
  - Prompts, system instructions, tool declarations, conversation text and attached images pass to OpenCode and its model provider. Do not send confidential data unless that provider's policies are suitable.
134
138
  - The extension does not directly extract credentials or call model HTTP endpoints. The OpenCode subprocess still runs as your user and can use its own configuration, credentials and inherited environment.
135
- - The native-tool guard is a guard rail, **not a sandbox**. An integration test runs the real OpenCode 1.19.0 CLI against a local model fixture that requests native `write` and `bash` calls, and checks that neither runs with the plugin intact, missing, or failing at startup. Other OpenCode versions, tools contributed by your own global OpenCode configuration (for example MCP servers), and a hostile local process are not covered.
139
+ - The native-tool guard is a guard rail, **not a sandbox**. Integration tests run the real OpenCode 1.19.0 and 2.0.30 CLIs against a local model fixture that requests native `write` and `bash`/`shell` calls, and check that neither runs with the legacy plugin intact, missing, or failing at startup. Other OpenCode versions, tools contributed by your own global OpenCode configuration (for example MCP servers), and a hostile local process are not covered.
136
140
  - Pi and OpenCode can both retain local conversation data. Best-effort cleanup is not a guarantee of secure deletion.
137
141
  - 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.
138
142
  - 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.
139
- - At startup Pi warns if your OpenCode version is not 1.19.0, the version the native-tool guard was verified with.
143
+ - At startup Pi warns when your OpenCode version exceeds the supported maximum, **2.0.30**, or its version cannot be determined.
140
144
  - 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.
141
145
 
142
146
  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.
@@ -151,7 +155,7 @@ OPENCODE_PI_INTEGRATION_BIN="$(which opencode)" npm run test:integration
151
155
  npm pack --dry-run
152
156
  ```
153
157
 
154
- `npm test` uses a fake OpenCode executable. `npm run test:integration` needs the real OpenCode CLI at exactly version 1.19.0 (on `PATH` or in `OPENCODE_PI_INTEGRATION_BIN`), runs it with isolated home and data directories against a local model fixture, and makes no network request. Neither suite needs API credentials. Development SDKs are pinned; Pi supplies those packages at runtime. The npm archive includes the extension source, README, security policy and license, not tests or installed dependencies.
158
+ `npm test` uses a fake OpenCode executable. `npm run test:integration` needs the real OpenCode CLI at version 1.19.0 or 2.0.30 (on `PATH` or in `OPENCODE_PI_INTEGRATION_BIN`), runs it with isolated home and data directories against a local model fixture, and makes no network request. Neither suite needs API credentials. Development SDKs are pinned; Pi supplies those packages at runtime. The npm archive includes the extension source, README, security policy and license, not tests or installed dependencies.
155
159
 
156
160
  ## License
157
161
 
package/SECURITY.md CHANGED
@@ -10,7 +10,7 @@ Never include prompts, conversation history, images, API keys, auth files or ses
10
10
  - Requests are delegated to the real OpenCode CLI. The extension does not directly read credentials, but the subprocess inherits the environment and uses OpenCode's own configuration and authentication.
11
11
  - System instructions, tool declarations, transcript text and image attachments can reach the selected model provider. Provider retention and training policies apply.
12
12
  - The current Pi tool list is checked before marker responses become Pi tool calls. Pi remains responsible for argument validation and tool permission checks.
13
- - Native OpenCode tools are blocked twice: a generated `tool.execute.before` plugin, and an agent permission of `ask` for everything, which non-interactive `opencode run` rejects. Native declarations remain visible to the model. Both layers depend on OpenCode 1.19.0 behavior, verified by an integration test with the real CLI. They are not a security boundary against a hostile local process, your own OpenCode configuration (it is loaded and may add tools or agent overrides), or other OpenCode versions.
13
+ - Native OpenCode tools are guarded by an agent permission of `ask` for everything, which non-interactive `opencode run` rejects. On 1.19.0 a generated `tool.execute.before` plugin provides a second guard; on 2.0.30 the bridge relies on permissions, not that legacy plugin. Native declarations remain visible to the model. Real-CLI integration tests on both releases request `write` and `bash`/`shell` side effects and verify that neither executes with the legacy plugin intact, missing, or failing. These guards are not a security boundary against a hostile local process, your own OpenCode configuration (it is loaded and may add tools or agent overrides), or other OpenCode versions.
14
14
  - Temporary project directories and OpenCode session records are removed best-effort. Pi's own conversation history is not deleted by the extension.
15
15
 
16
16
  ## Known gaps
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-opencode-cli-bridge",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
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
@@ -2,7 +2,7 @@ import { runOpenCodeProcess } from "./process.js";
2
2
  import { existsSync } from "node:fs";
3
3
  import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
- import { dirname, join, posix, win32 } from "node:path";
5
+ import { dirname, join, posix, resolve, 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,8 +29,13 @@ 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
+ const MAX_SUPPORTED_OPENCODE_VERSION = "2.0.30";
33
+ const cliVersions = new Map<string, string | undefined>();
34
+
35
+ async function usesV2Cli(): Promise<boolean> {
36
+ if (!cliVersions.has(opencodeBin())) await checkCliStatus();
37
+ return cliVersions.get(opencodeBin())?.startsWith("2.") === true;
38
+ }
34
39
  const DEFAULT_CONTEXT_WINDOW = 128_000;
35
40
  const DEFAULT_MAX_TOKENS = 16_384;
36
41
  const DISCOVERY_TIMEOUT_MS = 8_000;
@@ -110,7 +115,11 @@ let lastDiscoveryError: string | undefined;
110
115
 
111
116
  function opencodeBin(): string {
112
117
  const configured = process.env.OPENCODE_PI_BIN?.trim();
113
- if (configured) return configured;
118
+ if (configured) {
119
+ return configured.includes("/") || configured.includes("\\")
120
+ ? resolve(configured)
121
+ : configured;
122
+ }
114
123
  if (process.platform === "win32") {
115
124
  // npm's `opencode.cmd` shim cannot be launched by spawn() without a shell.
116
125
  // Resolve the native executable installed beside the active Node runtime.
@@ -310,51 +319,85 @@ export function parseVerboseModels(output: string): OpenCodeModelInfo[] {
310
319
  }
311
320
  if (typeof metadata !== "object" || metadata === null) continue;
312
321
 
313
- const value = metadata as {
314
- name?: unknown;
315
- status?: unknown;
316
- cost?: {
317
- input?: unknown;
318
- output?: unknown;
319
- cache?: { read?: unknown; write?: unknown };
320
- };
321
- limit?: { context?: unknown; output?: unknown };
322
- capabilities?: { reasoning?: unknown; input?: { image?: unknown } };
323
- variants?: unknown;
324
- };
325
- const reasoning = value.capabilities?.reasoning === true;
326
- const cost = parseModelCost(value);
327
- const costFromMetadata =
328
- costFigure(value.cost?.input) !== undefined &&
329
- costFigure(value.cost?.output) !== undefined;
330
- const status =
331
- typeof value.status === "string" && value.status.trim()
332
- ? value.status.trim()
333
- : undefined;
334
- records.set(id, {
335
- id,
336
- name:
337
- typeof value.name === "string" && value.name.trim()
338
- ? value.name.trim()
339
- : modelDisplayName(id),
340
- reasoning,
341
- image: value.capabilities?.input?.image === true,
342
- contextWindow: positiveNumber(
343
- value.limit?.context,
344
- contextWindowFor(id),
345
- ),
346
- maxTokens: positiveNumber(value.limit?.output, maxTokensFor(id)),
347
- cost,
348
- ...(costFromMetadata ? { costFromMetadata: true as const } : {}),
349
- ...(status !== undefined ? { status } : {}),
350
- ...(reasoning
351
- ? { thinkingLevelMap: thinkingLevelMap(value.variants) }
352
- : {}),
353
- });
322
+ records.set(id, modelInfo(id, metadata));
354
323
  }
355
324
  return [...records.values()];
356
325
  }
357
326
 
327
+ function modelInfo(id: string, metadata: unknown): OpenCodeModelInfo {
328
+ const value = metadata as {
329
+ name?: unknown;
330
+ status?: unknown;
331
+ cost?: {
332
+ input?: unknown;
333
+ output?: unknown;
334
+ cache?: { read?: unknown; write?: unknown };
335
+ };
336
+ limit?: { context?: unknown; output?: unknown };
337
+ capabilities?: { reasoning?: unknown; input?: { image?: unknown } };
338
+ variants?: unknown;
339
+ };
340
+ const reasoning = value.capabilities?.reasoning === true;
341
+ const cost = parseModelCost(value);
342
+ const costFromMetadata =
343
+ costFigure(value.cost?.input) !== undefined &&
344
+ costFigure(value.cost?.output) !== undefined;
345
+ const status =
346
+ typeof value.status === "string" && value.status.trim()
347
+ ? value.status.trim()
348
+ : undefined;
349
+ return {
350
+ id,
351
+ name:
352
+ typeof value.name === "string" && value.name.trim()
353
+ ? value.name.trim()
354
+ : modelDisplayName(id),
355
+ reasoning,
356
+ image: value.capabilities?.input?.image === true,
357
+ contextWindow: positiveNumber(value.limit?.context, contextWindowFor(id)),
358
+ maxTokens: positiveNumber(value.limit?.output, maxTokensFor(id)),
359
+ cost,
360
+ ...(costFromMetadata ? { costFromMetadata: true as const } : {}),
361
+ ...(status !== undefined ? { status } : {}),
362
+ ...(reasoning ? { thinkingLevelMap: thinkingLevelMap(value.variants) } : {}),
363
+ };
364
+ }
365
+
366
+ /** V2 exposes model metadata through its local API, not `models --verbose`. */
367
+ function parseV2Models(output: string): OpenCodeModelInfo[] {
368
+ const data = objectRecord(JSON.parse(output))?.data;
369
+ if (!Array.isArray(data)) return [];
370
+ return data.flatMap((entry) => {
371
+ const value = objectRecord(entry);
372
+ if (value?.providerID !== "opencode" || typeof value.id !== "string" ||
373
+ !value.id.trim() || /[\s/#]/.test(value.id) || value.enabled === false) return [];
374
+ const capabilities = objectRecord(value.capabilities);
375
+ const variants = Array.isArray(value.variants)
376
+ ? value.variants.flatMap((v) => typeof objectRecord(v)?.id === "string" ? [String(v.id)] : [])
377
+ : [];
378
+ const costs = Array.isArray(value.cost) ? value.cost : [];
379
+ // Any paid tier makes automatic selection unsafe, even when the base tier is free.
380
+ if (costs.some((c) => costFigure(objectRecord(c)?.input) === undefined ||
381
+ costFigure(objectRecord(c)?.output) === undefined)) return [];
382
+ const cost = costs.length ? {
383
+ input: Math.max(...costs.map(c => c.input)),
384
+ output: Math.max(...costs.map(c => c.output)),
385
+ cache: {
386
+ read: Math.max(...costs.map(c => costFigure(objectRecord(c.cache)?.read) ?? 0)),
387
+ write: Math.max(...costs.map(c => costFigure(objectRecord(c.cache)?.write) ?? 0)),
388
+ },
389
+ } : undefined;
390
+ return [modelInfo(`opencode/${value.id}`, {
391
+ ...value, cost,
392
+ capabilities: {
393
+ reasoning: variants.length > 0 || typeof objectRecord(value.compatibility)?.reasoningField === "string",
394
+ input: { image: Array.isArray(capabilities?.input) && capabilities.input.includes("image") },
395
+ },
396
+ variants: Object.fromEntries(variants.map(id => [id, {}])),
397
+ })];
398
+ });
399
+ }
400
+
358
401
  function runCapture(
359
402
  args: string[],
360
403
  input?: string,
@@ -390,6 +433,7 @@ export async function checkCliStatus(): Promise<CliStatus> {
390
433
  }
391
434
  const version =
392
435
  result.stdout.trim() || result.stderr.trim() || "OpenCode CLI is available";
436
+ cliVersions.set(opencodeBin(), /\d+\.\d+\.\d+/.exec(version)?.[0]);
393
437
  return { ok: true, summary: version };
394
438
  } catch (error) {
395
439
  return {
@@ -425,7 +469,10 @@ export async function discoverModels(opts?: {
425
469
  }
426
470
 
427
471
  try {
428
- const result = await runCapture(["models", "opencode", "--verbose"]);
472
+ const v2 = await usesV2Cli();
473
+ const result = await runCapture(v2
474
+ ? ["api", "--standalone", "get", "/api/model"]
475
+ : ["models", "opencode", "--verbose"]);
429
476
  if (result.code !== 0) {
430
477
  throw new Error(
431
478
  result.stderr.trim() ||
@@ -433,9 +480,9 @@ export async function discoverModels(opts?: {
433
480
  );
434
481
  }
435
482
 
436
- const metadata = parseVerboseModels(result.stdout);
483
+ const metadata = v2 ? parseV2Models(result.stdout) : parseVerboseModels(result.stdout);
437
484
  if (metadata.length === 0) {
438
- throw new Error("opencode verbose model discovery returned no metadata");
485
+ throw new Error("opencode model discovery returned no metadata");
439
486
  }
440
487
  const byId = new Map(metadata.map((model) => [model.id, model]));
441
488
  const selected = configured?.length
@@ -444,7 +491,7 @@ export async function discoverModels(opts?: {
444
491
  (model) => isActiveModel(model.status) && isFreeModel(model),
445
492
  );
446
493
  if (selected.length === 0) {
447
- throw new Error("opencode verbose model discovery returned no free models");
494
+ throw new Error("opencode model discovery returned no free models");
448
495
  }
449
496
 
450
497
  lastDiscoveryError = undefined;
@@ -642,31 +689,36 @@ export function translateNativeToolCall(
642
689
  name,
643
690
  arguments: Object.fromEntries(Object.entries(entries).filter(([, v]) => v !== undefined)),
644
691
  });
692
+ const fileKey = text("filePath") !== undefined ? "filePath" : "path";
645
693
  let result: ParsedToolCall | undefined;
646
694
  switch (tool) {
647
- case "bash": {
695
+ case "bash":
696
+ case "shell": {
648
697
  // shortcut: absolute temp-project paths inside the command are not rewritten; parse them if models rely on it.
649
698
  const timeout = count("timeout");
650
- if (text("command") === undefined || (args.workdir !== undefined && path("workdir") !== ".")) break;
699
+ if (text("command") === undefined ||
700
+ (args.workdir !== undefined && path("workdir") !== ".") ||
701
+ (tool === "shell" && (args.background === true || args.timeout === 0 ||
702
+ (args.cwd !== undefined && path("cwd") !== ".")))) break;
651
703
  result = call("bash", { command: text("command"), timeout: timeout && Math.ceil(timeout / 1000) });
652
704
  break;
653
705
  }
654
706
  case "read":
655
- if (text("filePath") !== undefined) {
656
- result = call("read", { path: path("filePath"), offset: count("offset"), limit: count("limit") });
707
+ if (text(fileKey) !== undefined) {
708
+ result = call("read", { path: path(fileKey), offset: count("offset"), limit: count("limit") });
657
709
  }
658
710
  break;
659
711
  case "write":
660
- if (text("filePath") !== undefined && text("content") !== undefined) {
661
- result = call("write", { path: path("filePath"), content: text("content") });
712
+ if (text(fileKey) !== undefined && text("content") !== undefined) {
713
+ result = call("write", { path: path(fileKey), content: text("content") });
662
714
  }
663
715
  break;
664
716
  case "edit":
665
717
  // Pi edits need one unique, non-empty match: no replaceAll or create-by-edit.
666
- if (text("filePath") !== undefined && text("oldString") && text("newString") !== undefined &&
718
+ if (text(fileKey) !== undefined && text("oldString") && text("newString") !== undefined &&
667
719
  args.replaceAll !== true) {
668
720
  result = call("edit", {
669
- path: path("filePath"),
721
+ path: path(fileKey),
670
722
  edits: [{ oldText: text("oldString"), newText: text("newString") }],
671
723
  });
672
724
  }
@@ -1264,7 +1316,9 @@ const pendingSessionDeletions = new Set<Promise<void>>();
1264
1316
  /** Best-effort, bounded deletion; shutdown also awaits one-shot deletions. */
1265
1317
  export function deleteOpenCodeSession(sessionId: string | undefined): Promise<void> {
1266
1318
  if (!sessionId) return Promise.resolve();
1267
- const task = runCapture(["session", "delete", sessionId], undefined, SESSION_DELETE_TIMEOUT_MS)
1319
+ const args = ["session", "delete", sessionId];
1320
+ if (cliVersions.get(opencodeBin())?.startsWith("2.")) args.push("--standalone");
1321
+ const task = runCapture(args, undefined, SESSION_DELETE_TIMEOUT_MS)
1268
1322
  .then(() => undefined, () => undefined);
1269
1323
  pendingSessionDeletions.add(task);
1270
1324
  void task.finally(() => pendingSessionDeletions.delete(task));
@@ -1437,8 +1491,8 @@ You are the OpenCode side of a Pi Coding Agent bridge. Do not use OpenCode nativ
1437
1491
  // the request. Permission `deny` removes them before inference and is now
1438
1492
  // rejected as a third-party client. Keep declarations intact, but block
1439
1493
  // every native execution before side effects occur. The agent's `ask`
1440
- // permission keeps declarations too, and `opencode run` auto-rejects asks,
1441
- // so a plugin that fails to load still cannot run native tools.
1494
+ // permission keeps declarations too, and `opencode run` auto-rejects asks.
1495
+ // V2 relies on that permission guard; the legacy plugin only guards V1.
1442
1496
  await writeFile(
1443
1497
  join(pluginsDir, "pi-native-tool-blocker.js"),
1444
1498
  `export const PiNativeToolBlocker = async () => ({
@@ -1456,8 +1510,18 @@ export function streamOpenCode(
1456
1510
  model: Model<Api>,
1457
1511
  context: Context,
1458
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
+ allowNativeRecovery: boolean,
1459
1522
  ): AssistantMessageEventStream {
1460
1523
  const stream = createAssistantMessageEventStream();
1524
+ const deadline = Date.now() + resolveTurnTimeoutMs(options?.timeoutMs);
1461
1525
 
1462
1526
  (async () => {
1463
1527
  const output: AssistantMessage = {
@@ -1472,12 +1536,14 @@ export function streamOpenCode(
1472
1536
  };
1473
1537
 
1474
1538
  let tempDir: string | undefined;
1539
+ let prompt = "";
1475
1540
  let accumulatedText = "";
1476
1541
  let accumulatedThinking = "";
1477
1542
  let stderr = "";
1478
1543
  let stdoutRemainder = "";
1479
1544
  let capturedSessionId: string | undefined;
1480
1545
  let opencodeToolUse: string | undefined;
1546
+ let nativeToolFailure: Error | undefined;
1481
1547
  // Text after the last blocked native call shows whether the model recovered in-run.
1482
1548
  let nativeTextMark = 0;
1483
1549
  let stepNativeCalls: (ParsedToolCall | undefined)[] = [];
@@ -1523,7 +1589,7 @@ export function streamOpenCode(
1523
1589
  dropOpencodeSession(piState);
1524
1590
  }
1525
1591
  const isContinuation = delta !== undefined;
1526
- let prompt = isContinuation
1592
+ prompt = isContinuation
1527
1593
  ? buildContinuationPrompt(delta)
1528
1594
  : buildPrompt(context);
1529
1595
  const sentMessages = isContinuation
@@ -1544,26 +1610,24 @@ export function streamOpenCode(
1544
1610
  );
1545
1611
  const imagePaths = await writeImageFiles(images, sessionDir);
1546
1612
  if (options?.signal?.aborted) throw new Error("Request was aborted");
1613
+ const v2 = await usesV2Cli();
1614
+ const requestedReasoning = options?.reasoning as ModelThinkingLevel | undefined;
1615
+ const variant = requestedReasoning && requestedReasoning !== "off"
1616
+ ? model.thinkingLevelMap?.[requestedReasoning] : undefined;
1547
1617
  const args = [
1548
1618
  "run",
1549
1619
  "-m",
1550
- model.id,
1620
+ v2 && typeof variant === "string" && variant.trim() ? `${model.id}#${variant}` : model.id,
1551
1621
  "--agent",
1552
1622
  AGENT_ID,
1553
1623
  "--format",
1554
1624
  "json",
1555
- "--dir",
1556
- sessionDir,
1625
+ ...(v2 ? ["--standalone"] : ["--dir", sessionDir]),
1557
1626
  ];
1558
1627
  if (isContinuation && piState?.opencodeSessionId) {
1559
1628
  args.push("--session", piState.opencodeSessionId);
1560
1629
  }
1561
- const requestedReasoning = options?.reasoning as
1562
- | ModelThinkingLevel
1563
- | undefined;
1564
- args.push(
1565
- ...reasoningCliArgs(requestedReasoning, model.thinkingLevelMap),
1566
- );
1630
+ args.push(...reasoningCliArgs(requestedReasoning, v2 ? undefined : model.thinkingLevelMap));
1567
1631
  for (const path of imagePaths) args.push("--file", path);
1568
1632
 
1569
1633
  const handleLine = async (line: string) => {
@@ -1658,6 +1722,7 @@ export function streamOpenCode(
1658
1722
  };
1659
1723
 
1660
1724
  const result = await runOpenCodeProcess(opencodeBin(), args, {
1725
+ cwd: v2 ? sessionDir : undefined,
1661
1726
  input: async () => {
1662
1727
  if (!payloadHook) return prompt;
1663
1728
  const replacement = await payloadHook({ prompt }, model);
@@ -1678,6 +1743,7 @@ export function streamOpenCode(
1678
1743
  env: {
1679
1744
  ...process.env,
1680
1745
  ...options?.env,
1746
+ ...(v2 ? { PWD: sessionDir } : {}),
1681
1747
  OPENCODE_DISABLE_UPDATE_CHECK: "1",
1682
1748
  OPENCODE_CONFIG_DIR: join(sessionDir, ".opencode"),
1683
1749
  },
@@ -1701,9 +1767,10 @@ export function streamOpenCode(
1701
1767
  }
1702
1768
  if (opencodeError) throw new Error(opencodeError);
1703
1769
  if (opencodeToolUse && !nativeCalls && !accumulatedText.slice(nativeTextMark).trim()) {
1704
- throw new Error(
1705
- `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
+ nativeToolFailure = new Error(
1771
+ `OpenCode could not use the available Pi tools${allowNativeRecovery ? "" : " after one recovery attempt"} (blocked native tool: ${JSON.stringify(opencodeToolUse)}). Retry the request or select another OpenCode model.`,
1706
1772
  );
1773
+ throw nativeToolFailure;
1707
1774
  }
1708
1775
 
1709
1776
  const toolCallResult = parseToolCallResponse(
@@ -1875,6 +1942,36 @@ export function streamOpenCode(
1875
1942
  // deleted best-effort and never fails the turn (#71, #75).
1876
1943
  deleteOpenCodeSession(capturedSessionId);
1877
1944
  if (piState) dropOpencodeSession(piState);
1945
+ const remainingMs = deadline - Date.now();
1946
+ if (nativeToolFailure && error === nativeToolFailure && allowNativeRecovery &&
1947
+ !options?.signal?.aborted && remainingMs > 0 && context.tools?.length) {
1948
+ setEstimatedUsage(model, output, prompt, accumulatedText + accumulatedThinking);
1949
+ const correction = [
1950
+ `Your previous attempt used the disabled OpenCode native tool ${JSON.stringify(opencodeToolUse)}.`,
1951
+ `Available Pi tools: ${context.tools.map(tool => tool.name).join(", ")}.`,
1952
+ "Complete the original request using only those Pi tools. Do not call any OpenCode native tool.",
1953
+ 'Request Pi tools as <pi_tool_call>{"name":"tool_name","arguments":{}}</pi_tool_call> markers, or answer normally if no tool is needed.',
1954
+ "If Pi has no grep tool, request a search through Pi bash when it is available instead.",
1955
+ ].join("\n");
1956
+ // A fresh attempt keeps rejected native calls out of the continued transcript.
1957
+ const recovery = streamOpenCodeAttempt(model, {
1958
+ ...context,
1959
+ messages: [...context.messages, { role: "user", content: correction, timestamp: Date.now() }],
1960
+ }, { ...options, timeoutMs: remainingMs }, false);
1961
+ for await (const event of recovery) {
1962
+ if (event.type === "start") continue;
1963
+ const message = event.type === "done" ? event.message : event.type === "error" ? event.error : undefined;
1964
+ if (message) {
1965
+ for (const key of ["input", "output", "cacheRead", "cacheWrite", "totalTokens"] as const) {
1966
+ message.usage[key] += output.usage[key];
1967
+ }
1968
+ calculateCost(model, message.usage);
1969
+ }
1970
+ stream.push(event);
1971
+ }
1972
+ stream.end();
1973
+ return;
1974
+ }
1878
1975
  output.stopReason = options?.signal?.aborted ? "aborted" : "error";
1879
1976
  output.errorMessage =
1880
1977
  error instanceof Error ? error.message : String(error);
@@ -1998,9 +2095,9 @@ export default async function opencodePiExtension(pi: ExtensionAPI) {
1998
2095
  "info",
1999
2096
  );
2000
2097
  const version = /\d+\.\d+\.\d+/.exec(cliStatus.summary)?.[0];
2001
- if (version !== VERIFIED_OPENCODE_VERSION) {
2098
+ if (!version || version.localeCompare(MAX_SUPPORTED_OPENCODE_VERSION, undefined, { numeric: true }) > 0) {
2002
2099
  ctx.ui.notify(
2003
- `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.`,
2100
+ `opencode-pi: detected OpenCode ${version ?? cliStatus.summary}. This bridge supports OpenCode up to ${MAX_SUPPORTED_OPENCODE_VERSION}. Please install a supported version.`,
2004
2101
  "warning",
2005
2102
  );
2006
2103
  }
package/src/process.ts CHANGED
@@ -5,6 +5,7 @@ export type ProcessOptions = {
5
5
  timeoutMs: number;
6
6
  input?: string | (() => string | Promise<string>);
7
7
  env?: NodeJS.ProcessEnv;
8
+ cwd?: string;
8
9
  signal?: AbortSignal;
9
10
  onStart?: () => void | Promise<void>;
10
11
  onStdout?: (chunk: string) => void | Promise<void>;
@@ -21,6 +22,7 @@ export async function runOpenCodeProcess(
21
22
  const child = spawn(executable, args, {
22
23
  stdio: ["pipe", "pipe", "pipe"],
23
24
  env: options.env,
25
+ cwd: options.cwd,
24
26
  // The npm launcher can spawn the native CLI; terminate that entire group.
25
27
  detached: process.platform !== "win32",
26
28
  });