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 +12 -8
- package/SECURITY.md +1 -1
- package/package.json +1 -1
- package/src/index.ts +126 -71
- package/src/process.ts +2 -0
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
|
|
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
|
|
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**.
|
|
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
|
|
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
|
|
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
|
|
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
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
|
-
//
|
|
33
|
-
const
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 ||
|
|
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(
|
|
656
|
-
result = call("read", { path: path(
|
|
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(
|
|
661
|
-
result = call("write", { path: path(
|
|
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(
|
|
719
|
+
if (text(fileKey) !== undefined && text("oldString") && text("newString") !== undefined &&
|
|
667
720
|
args.replaceAll !== true) {
|
|
668
721
|
result = call("edit", {
|
|
669
|
-
path: path(
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
2056
|
+
if (!version || !VERIFIED_OPENCODE_VERSIONS.includes(version)) {
|
|
2002
2057
|
ctx.ui.notify(
|
|
2003
|
-
`opencode-pi: OpenCode ${version ?? cliStatus.summary} is not ${
|
|
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
|
});
|