pi-opencode-cli-bridge 0.2.4 → 0.2.5

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`. Tested with OpenCode **1.19.0** and **2.0.30**; other releases are unverified.
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: the model sees a correction asking it to use a Pi tool, and the turn fails only if it does not recover.
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
 
@@ -132,11 +136,11 @@ Reasoning variants, image support and token limits follow CLI discovery. Token u
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 if your OpenCode version is neither 1.19.0 nor 2.0.30, the exact releases covered by the native-tool guard tests.
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.5",
4
4
  "description": "Bridge the OpenCode CLI into Pi Coding Agent",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
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,14 @@ 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
+ // Only exact releases exercised by the real-CLI native-side-effect tests belong here.
33
+ const VERIFIED_OPENCODE_VERSIONS = ["1.19.0", "2.0.30"];
34
+ const cliVersions = new Map<string, string | undefined>();
35
+
36
+ async function usesV2Cli(): Promise<boolean> {
37
+ if (!cliVersions.has(opencodeBin())) await checkCliStatus();
38
+ return cliVersions.get(opencodeBin())?.startsWith("2.") === true;
39
+ }
34
40
  const DEFAULT_CONTEXT_WINDOW = 128_000;
35
41
  const DEFAULT_MAX_TOKENS = 16_384;
36
42
  const DISCOVERY_TIMEOUT_MS = 8_000;
@@ -110,7 +116,11 @@ let lastDiscoveryError: string | undefined;
110
116
 
111
117
  function opencodeBin(): string {
112
118
  const configured = process.env.OPENCODE_PI_BIN?.trim();
113
- if (configured) return configured;
119
+ if (configured) {
120
+ return configured.includes("/") || configured.includes("\\")
121
+ ? resolve(configured)
122
+ : configured;
123
+ }
114
124
  if (process.platform === "win32") {
115
125
  // npm's `opencode.cmd` shim cannot be launched by spawn() without a shell.
116
126
  // Resolve the native executable installed beside the active Node runtime.
@@ -310,51 +320,85 @@ export function parseVerboseModels(output: string): OpenCodeModelInfo[] {
310
320
  }
311
321
  if (typeof metadata !== "object" || metadata === null) continue;
312
322
 
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
- });
323
+ records.set(id, modelInfo(id, metadata));
354
324
  }
355
325
  return [...records.values()];
356
326
  }
357
327
 
328
+ function modelInfo(id: string, metadata: unknown): OpenCodeModelInfo {
329
+ const value = metadata as {
330
+ name?: unknown;
331
+ status?: unknown;
332
+ cost?: {
333
+ input?: unknown;
334
+ output?: unknown;
335
+ cache?: { read?: unknown; write?: unknown };
336
+ };
337
+ limit?: { context?: unknown; output?: unknown };
338
+ capabilities?: { reasoning?: unknown; input?: { image?: unknown } };
339
+ variants?: unknown;
340
+ };
341
+ const reasoning = value.capabilities?.reasoning === true;
342
+ const cost = parseModelCost(value);
343
+ const costFromMetadata =
344
+ costFigure(value.cost?.input) !== undefined &&
345
+ costFigure(value.cost?.output) !== undefined;
346
+ const status =
347
+ typeof value.status === "string" && value.status.trim()
348
+ ? value.status.trim()
349
+ : undefined;
350
+ return {
351
+ id,
352
+ name:
353
+ typeof value.name === "string" && value.name.trim()
354
+ ? value.name.trim()
355
+ : modelDisplayName(id),
356
+ reasoning,
357
+ image: value.capabilities?.input?.image === true,
358
+ contextWindow: positiveNumber(value.limit?.context, contextWindowFor(id)),
359
+ maxTokens: positiveNumber(value.limit?.output, maxTokensFor(id)),
360
+ cost,
361
+ ...(costFromMetadata ? { costFromMetadata: true as const } : {}),
362
+ ...(status !== undefined ? { status } : {}),
363
+ ...(reasoning ? { thinkingLevelMap: thinkingLevelMap(value.variants) } : {}),
364
+ };
365
+ }
366
+
367
+ /** V2 exposes model metadata through its local API, not `models --verbose`. */
368
+ function parseV2Models(output: string): OpenCodeModelInfo[] {
369
+ const data = objectRecord(JSON.parse(output))?.data;
370
+ if (!Array.isArray(data)) return [];
371
+ return data.flatMap((entry) => {
372
+ const value = objectRecord(entry);
373
+ if (value?.providerID !== "opencode" || typeof value.id !== "string" ||
374
+ !value.id.trim() || /[\s/#]/.test(value.id) || value.enabled === false) return [];
375
+ const capabilities = objectRecord(value.capabilities);
376
+ const variants = Array.isArray(value.variants)
377
+ ? value.variants.flatMap((v) => typeof objectRecord(v)?.id === "string" ? [String(v.id)] : [])
378
+ : [];
379
+ const costs = Array.isArray(value.cost) ? value.cost : [];
380
+ // Any paid tier makes automatic selection unsafe, even when the base tier is free.
381
+ if (costs.some((c) => costFigure(objectRecord(c)?.input) === undefined ||
382
+ costFigure(objectRecord(c)?.output) === undefined)) return [];
383
+ const cost = costs.length ? {
384
+ input: Math.max(...costs.map(c => c.input)),
385
+ output: Math.max(...costs.map(c => c.output)),
386
+ cache: {
387
+ read: Math.max(...costs.map(c => costFigure(objectRecord(c.cache)?.read) ?? 0)),
388
+ write: Math.max(...costs.map(c => costFigure(objectRecord(c.cache)?.write) ?? 0)),
389
+ },
390
+ } : undefined;
391
+ return [modelInfo(`opencode/${value.id}`, {
392
+ ...value, cost,
393
+ capabilities: {
394
+ reasoning: variants.length > 0 || typeof objectRecord(value.compatibility)?.reasoningField === "string",
395
+ input: { image: Array.isArray(capabilities?.input) && capabilities.input.includes("image") },
396
+ },
397
+ variants: Object.fromEntries(variants.map(id => [id, {}])),
398
+ })];
399
+ });
400
+ }
401
+
358
402
  function runCapture(
359
403
  args: string[],
360
404
  input?: string,
@@ -390,6 +434,7 @@ export async function checkCliStatus(): Promise<CliStatus> {
390
434
  }
391
435
  const version =
392
436
  result.stdout.trim() || result.stderr.trim() || "OpenCode CLI is available";
437
+ cliVersions.set(opencodeBin(), /\d+\.\d+\.\d+/.exec(version)?.[0]);
393
438
  return { ok: true, summary: version };
394
439
  } catch (error) {
395
440
  return {
@@ -425,7 +470,10 @@ export async function discoverModels(opts?: {
425
470
  }
426
471
 
427
472
  try {
428
- const result = await runCapture(["models", "opencode", "--verbose"]);
473
+ const v2 = await usesV2Cli();
474
+ const result = await runCapture(v2
475
+ ? ["api", "--standalone", "get", "/api/model"]
476
+ : ["models", "opencode", "--verbose"]);
429
477
  if (result.code !== 0) {
430
478
  throw new Error(
431
479
  result.stderr.trim() ||
@@ -433,9 +481,9 @@ export async function discoverModels(opts?: {
433
481
  );
434
482
  }
435
483
 
436
- const metadata = parseVerboseModels(result.stdout);
484
+ const metadata = v2 ? parseV2Models(result.stdout) : parseVerboseModels(result.stdout);
437
485
  if (metadata.length === 0) {
438
- throw new Error("opencode verbose model discovery returned no metadata");
486
+ throw new Error("opencode model discovery returned no metadata");
439
487
  }
440
488
  const byId = new Map(metadata.map((model) => [model.id, model]));
441
489
  const selected = configured?.length
@@ -444,7 +492,7 @@ export async function discoverModels(opts?: {
444
492
  (model) => isActiveModel(model.status) && isFreeModel(model),
445
493
  );
446
494
  if (selected.length === 0) {
447
- throw new Error("opencode verbose model discovery returned no free models");
495
+ throw new Error("opencode model discovery returned no free models");
448
496
  }
449
497
 
450
498
  lastDiscoveryError = undefined;
@@ -642,31 +690,36 @@ export function translateNativeToolCall(
642
690
  name,
643
691
  arguments: Object.fromEntries(Object.entries(entries).filter(([, v]) => v !== undefined)),
644
692
  });
693
+ const fileKey = text("filePath") !== undefined ? "filePath" : "path";
645
694
  let result: ParsedToolCall | undefined;
646
695
  switch (tool) {
647
- case "bash": {
696
+ case "bash":
697
+ case "shell": {
648
698
  // shortcut: absolute temp-project paths inside the command are not rewritten; parse them if models rely on it.
649
699
  const timeout = count("timeout");
650
- if (text("command") === undefined || (args.workdir !== undefined && path("workdir") !== ".")) break;
700
+ if (text("command") === undefined ||
701
+ (args.workdir !== undefined && path("workdir") !== ".") ||
702
+ (tool === "shell" && (args.background === true || args.timeout === 0 ||
703
+ (args.cwd !== undefined && path("cwd") !== ".")))) break;
651
704
  result = call("bash", { command: text("command"), timeout: timeout && Math.ceil(timeout / 1000) });
652
705
  break;
653
706
  }
654
707
  case "read":
655
- if (text("filePath") !== undefined) {
656
- result = call("read", { path: path("filePath"), offset: count("offset"), limit: count("limit") });
708
+ if (text(fileKey) !== undefined) {
709
+ result = call("read", { path: path(fileKey), offset: count("offset"), limit: count("limit") });
657
710
  }
658
711
  break;
659
712
  case "write":
660
- if (text("filePath") !== undefined && text("content") !== undefined) {
661
- result = call("write", { path: path("filePath"), content: text("content") });
713
+ if (text(fileKey) !== undefined && text("content") !== undefined) {
714
+ result = call("write", { path: path(fileKey), content: text("content") });
662
715
  }
663
716
  break;
664
717
  case "edit":
665
718
  // Pi edits need one unique, non-empty match: no replaceAll or create-by-edit.
666
- if (text("filePath") !== undefined && text("oldString") && text("newString") !== undefined &&
719
+ if (text(fileKey) !== undefined && text("oldString") && text("newString") !== undefined &&
667
720
  args.replaceAll !== true) {
668
721
  result = call("edit", {
669
- path: path("filePath"),
722
+ path: path(fileKey),
670
723
  edits: [{ oldText: text("oldString"), newText: text("newString") }],
671
724
  });
672
725
  }
@@ -1264,7 +1317,9 @@ const pendingSessionDeletions = new Set<Promise<void>>();
1264
1317
  /** Best-effort, bounded deletion; shutdown also awaits one-shot deletions. */
1265
1318
  export function deleteOpenCodeSession(sessionId: string | undefined): Promise<void> {
1266
1319
  if (!sessionId) return Promise.resolve();
1267
- const task = runCapture(["session", "delete", sessionId], undefined, SESSION_DELETE_TIMEOUT_MS)
1320
+ const args = ["session", "delete", sessionId];
1321
+ if (cliVersions.get(opencodeBin())?.startsWith("2.")) args.push("--standalone");
1322
+ const task = runCapture(args, undefined, SESSION_DELETE_TIMEOUT_MS)
1268
1323
  .then(() => undefined, () => undefined);
1269
1324
  pendingSessionDeletions.add(task);
1270
1325
  void task.finally(() => pendingSessionDeletions.delete(task));
@@ -1437,8 +1492,8 @@ You are the OpenCode side of a Pi Coding Agent bridge. Do not use OpenCode nativ
1437
1492
  // the request. Permission `deny` removes them before inference and is now
1438
1493
  // rejected as a third-party client. Keep declarations intact, but block
1439
1494
  // 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.
1495
+ // permission keeps declarations too, and `opencode run` auto-rejects asks.
1496
+ // V2 relies on that permission guard; the legacy plugin only guards V1.
1442
1497
  await writeFile(
1443
1498
  join(pluginsDir, "pi-native-tool-blocker.js"),
1444
1499
  `export const PiNativeToolBlocker = async () => ({
@@ -1544,26 +1599,24 @@ export function streamOpenCode(
1544
1599
  );
1545
1600
  const imagePaths = await writeImageFiles(images, sessionDir);
1546
1601
  if (options?.signal?.aborted) throw new Error("Request was aborted");
1602
+ const v2 = await usesV2Cli();
1603
+ const requestedReasoning = options?.reasoning as ModelThinkingLevel | undefined;
1604
+ const variant = requestedReasoning && requestedReasoning !== "off"
1605
+ ? model.thinkingLevelMap?.[requestedReasoning] : undefined;
1547
1606
  const args = [
1548
1607
  "run",
1549
1608
  "-m",
1550
- model.id,
1609
+ v2 && typeof variant === "string" && variant.trim() ? `${model.id}#${variant}` : model.id,
1551
1610
  "--agent",
1552
1611
  AGENT_ID,
1553
1612
  "--format",
1554
1613
  "json",
1555
- "--dir",
1556
- sessionDir,
1614
+ ...(v2 ? ["--standalone"] : ["--dir", sessionDir]),
1557
1615
  ];
1558
1616
  if (isContinuation && piState?.opencodeSessionId) {
1559
1617
  args.push("--session", piState.opencodeSessionId);
1560
1618
  }
1561
- const requestedReasoning = options?.reasoning as
1562
- | ModelThinkingLevel
1563
- | undefined;
1564
- args.push(
1565
- ...reasoningCliArgs(requestedReasoning, model.thinkingLevelMap),
1566
- );
1619
+ args.push(...reasoningCliArgs(requestedReasoning, v2 ? undefined : model.thinkingLevelMap));
1567
1620
  for (const path of imagePaths) args.push("--file", path);
1568
1621
 
1569
1622
  const handleLine = async (line: string) => {
@@ -1658,6 +1711,7 @@ export function streamOpenCode(
1658
1711
  };
1659
1712
 
1660
1713
  const result = await runOpenCodeProcess(opencodeBin(), args, {
1714
+ cwd: v2 ? sessionDir : undefined,
1661
1715
  input: async () => {
1662
1716
  if (!payloadHook) return prompt;
1663
1717
  const replacement = await payloadHook({ prompt }, model);
@@ -1678,6 +1732,7 @@ export function streamOpenCode(
1678
1732
  env: {
1679
1733
  ...process.env,
1680
1734
  ...options?.env,
1735
+ ...(v2 ? { PWD: sessionDir } : {}),
1681
1736
  OPENCODE_DISABLE_UPDATE_CHECK: "1",
1682
1737
  OPENCODE_CONFIG_DIR: join(sessionDir, ".opencode"),
1683
1738
  },
@@ -1998,9 +2053,9 @@ export default async function opencodePiExtension(pi: ExtensionAPI) {
1998
2053
  "info",
1999
2054
  );
2000
2055
  const version = /\d+\.\d+\.\d+/.exec(cliStatus.summary)?.[0];
2001
- if (version !== VERIFIED_OPENCODE_VERSION) {
2056
+ if (!version || !VERIFIED_OPENCODE_VERSIONS.includes(version)) {
2002
2057
  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.`,
2058
+ `opencode-pi: OpenCode ${version ?? cliStatus.summary} is not one of ${VERIFIED_OPENCODE_VERSIONS.join(", ")}, the versions the native-tool guard was verified with. Native OpenCode tools may not stay blocked.`,
2004
2059
  "warning",
2005
2060
  );
2006
2061
  }
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
  });