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 +13 -9
- package/SECURITY.md +1 -1
- package/package.json +2 -2
- package/src/index.ts +171 -74
- 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`.
|
|
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
|
|
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
|
|
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**.
|
|
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
|
|
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
|
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-opencode-cli-bridge",
|
|
3
|
-
"version": "0.2.
|
|
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=
|
|
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
|
-
|
|
33
|
-
const
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 ||
|
|
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(
|
|
656
|
-
result = call("read", { path: path(
|
|
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(
|
|
661
|
-
result = call("write", { path: path(
|
|
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(
|
|
718
|
+
if (text(fileKey) !== undefined && text("oldString") && text("newString") !== undefined &&
|
|
667
719
|
args.replaceAll !== true) {
|
|
668
720
|
result = call("edit", {
|
|
669
|
-
path: path(
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1705
|
-
`OpenCode
|
|
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
|
|
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}
|
|
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
|
});
|