pi-opencode-cli-bridge 0.1.0 → 0.2.0
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 +6 -3
- package/package.json +13 -4
- package/src/index.ts +141 -29
- package/src/process.ts +9 -3
package/README.md
CHANGED
|
@@ -15,6 +15,7 @@ This fork fixes that and closes the gaps found while reviewing the code:
|
|
|
15
15
|
- **No hung turns or orphaned processes.** Timeouts and cancellation kill the OpenCode process group, even when it ignores `SIGTERM`.
|
|
16
16
|
- **Unexpected CLI output no longer crashes Pi.** Malformed events end the turn with an error.
|
|
17
17
|
- **Clean shutdown.** Pi waits for temporary directories and OpenCode session records to be removed.
|
|
18
|
+
- **Fewer failed turns when the model reaches for OpenCode's own tools.** A blocked native `bash`, `read`, `write`, `edit`, `grep` or `glob` call with a Pi equivalent is handed to Pi as a normal tool call. For other tools, the model is told how to ask Pi instead and can recover in the same run.
|
|
18
19
|
- **Standard Pi provider hooks.** `onPayload`, `onResponse` and `onProviderStreamEvent` work as with built-in providers.
|
|
19
20
|
- **Its own npm package**, with the original authors credited.
|
|
20
21
|
|
|
@@ -114,8 +115,9 @@ Without an override, discovery selects active models whose reported input and ou
|
|
|
114
115
|
2. It replays Pi's transcript system messages to recover the current system prompt and tool declarations, including later changes.
|
|
115
116
|
3. It creates a temporary OpenCode project containing a `pi-model` agent and a `tool.execute.before` plugin. Native tool declarations remain on the request. Execution is stopped twice: the plugin throws before any native tool runs, and the agent sets every permission to `ask`, which non-interactive `opencode run` rejects automatically. The second layer still holds if the plugin is missing or fails to load.
|
|
116
117
|
4. It sends the prompt over standard input to `opencode run --format json`. User and tool-result images are written to temporary files and attached only when the model supports images.
|
|
117
|
-
5.
|
|
118
|
-
6.
|
|
118
|
+
5. If the model calls a native OpenCode tool anyway, the call is still blocked. When every native call in that step has a Pi equivalent in the current tool list (`bash`, `read`, `write`, `edit` with a single replacement, `grep`, and `glob` as `find`), the bridge stops OpenCode and returns them as Pi tool calls. Paths inside the temporary project become relative to Pi's working directory. Otherwise OpenCode continues: the model sees a correction asking it to use a Pi tool, and the turn fails only if it does not recover.
|
|
119
|
+
6. Text markers such as `<pi_tool_call>{"name":"read","arguments":{"path":"example.txt"}}</pi_tool_call>` become Pi tool calls. Tool names must be in the current Pi tool list; execution then follows Pi's normal validation and permission pipeline.
|
|
120
|
+
7. Successful continuation turns reuse an OpenCode session and send only the new transcript. Changes to the model, prompt, tools or transcript prefix start a fresh session.
|
|
119
121
|
|
|
120
122
|
Pi's provider instrumentation hooks follow the CLI transport, not OpenCode's internal HTTP traffic:
|
|
121
123
|
|
|
@@ -134,7 +136,8 @@ Reasoning variants, image support and token limits follow CLI discovery. Token u
|
|
|
134
136
|
- Pi and OpenCode can both retain local conversation data. Best-effort cleanup is not a guarantee of secure deletion.
|
|
135
137
|
- Free-tier model availability and accepted request shapes can change. This fork makes no guarantee of continued access or compliance with a hosted provider's terms; review the applicable terms yourself.
|
|
136
138
|
- Live tests with DeepSeek v4.1 Flash Free used thinking off. The first high-thinking test timed out; no default thinking setting is changed by this package.
|
|
137
|
-
-
|
|
139
|
+
- At startup Pi warns if your OpenCode version is not 1.19.0, the version the native-tool guard was verified with.
|
|
140
|
+
- Timeouts and cancellation escalate from `SIGTERM` to `SIGKILL` on the CLI's process group after 250 ms; on Windows the whole process tree is ended with `taskkill /T /F` (not tested on Windows). If OpenCode quits before reading the prompt, the turn shows OpenCode's own error rather than a broken-pipe error. Malformed or unexpected CLI events end the turn with an error instead of crashing Pi. Shutdown waits for session cleanup.
|
|
138
141
|
|
|
139
142
|
See [SECURITY.md](SECURITY.md) for the trust boundaries and [docs/code-review.md](https://github.com/Gybra/pi-opencode-cli-bridge/blob/main/docs/code-review.md) for what was fixed and what remains. This is an experimental, unaudited fork.
|
|
140
143
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-opencode-cli-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Bridge the OpenCode CLI into Pi Coding Agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -34,9 +34,18 @@
|
|
|
34
34
|
],
|
|
35
35
|
"author": "pi-extensions contributors",
|
|
36
36
|
"contributors": [
|
|
37
|
-
{
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
{
|
|
38
|
+
"name": "luongnv89",
|
|
39
|
+
"url": "https://github.com/luongnv89"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"name": "Rodrigo Manuel Jager Pinheiro",
|
|
43
|
+
"url": "https://github.com/rodrigojager"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"name": "Gybra",
|
|
47
|
+
"url": "https://github.com/Gybra"
|
|
48
|
+
}
|
|
40
49
|
],
|
|
41
50
|
"license": "MIT",
|
|
42
51
|
"engines": {
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { runOpenCodeProcess } from "./process.js";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
|
-
import { dirname, join } from "node:path";
|
|
5
|
+
import { dirname, join, posix, win32 } from "node:path";
|
|
6
6
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { registerApiProvider } from "@earendil-works/pi-ai/compat";
|
|
8
8
|
import {
|
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
const PROVIDER_ID = "opencode-cli";
|
|
30
30
|
const API_ID = "opencode-cli-runner";
|
|
31
31
|
const AGENT_ID = "pi-model";
|
|
32
|
+
// The native-tool guard is verified against this CLI release by the integration test.
|
|
33
|
+
const VERIFIED_OPENCODE_VERSION = "1.19.0";
|
|
32
34
|
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
33
35
|
const DEFAULT_MAX_TOKENS = 16_384;
|
|
34
36
|
const DISCOVERY_TIMEOUT_MS = 8_000;
|
|
@@ -608,6 +610,78 @@ function objectRecord(value: unknown): Record<string, unknown> | undefined {
|
|
|
608
610
|
: undefined;
|
|
609
611
|
}
|
|
610
612
|
|
|
613
|
+
/**
|
|
614
|
+
* Map a blocked native OpenCode call onto the equivalent Pi tool, so Pi runs
|
|
615
|
+
* it under its own permissions. Calls whose semantics differ stay untranslated.
|
|
616
|
+
*/
|
|
617
|
+
export function translateNativeToolCall(
|
|
618
|
+
tool: string,
|
|
619
|
+
input: unknown,
|
|
620
|
+
allowedToolNames: Set<string>,
|
|
621
|
+
projectDirs: string[],
|
|
622
|
+
paths: typeof posix = process.platform === "win32" ? win32 : posix,
|
|
623
|
+
): ParsedToolCall | undefined {
|
|
624
|
+
const args = objectRecord(input);
|
|
625
|
+
if (!args) return undefined;
|
|
626
|
+
const text = (key: string) => typeof args[key] === "string" ? args[key] as string : undefined;
|
|
627
|
+
const count = (key: string) =>
|
|
628
|
+
typeof args[key] === "number" && args[key] > 0 ? args[key] as number : undefined;
|
|
629
|
+
// OpenCode resolves paths in its temporary project; Pi resolves them in the real one.
|
|
630
|
+
// Platform path rules also cover Windows slash and drive-letter case differences.
|
|
631
|
+
const path = (key: string) => {
|
|
632
|
+
const value = text(key);
|
|
633
|
+
if (value === undefined || !paths.isAbsolute(value)) return value;
|
|
634
|
+
for (const dir of projectDirs) {
|
|
635
|
+
const rel = paths.relative(dir, value);
|
|
636
|
+
if (rel !== ".." && !rel.startsWith(".." + paths.sep) && !paths.isAbsolute(rel)) return rel || ".";
|
|
637
|
+
}
|
|
638
|
+
return value;
|
|
639
|
+
};
|
|
640
|
+
const call = (name: string, entries: Record<string, unknown>): ParsedToolCall => ({
|
|
641
|
+
name,
|
|
642
|
+
arguments: Object.fromEntries(Object.entries(entries).filter(([, v]) => v !== undefined)),
|
|
643
|
+
});
|
|
644
|
+
let result: ParsedToolCall | undefined;
|
|
645
|
+
switch (tool) {
|
|
646
|
+
case "bash": {
|
|
647
|
+
// shortcut: absolute temp-project paths inside the command are not rewritten; parse them if models rely on it.
|
|
648
|
+
const timeout = count("timeout");
|
|
649
|
+
if (text("command") === undefined || (args.workdir !== undefined && path("workdir") !== ".")) break;
|
|
650
|
+
result = call("bash", { command: text("command"), timeout: timeout && Math.ceil(timeout / 1000) });
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
case "read":
|
|
654
|
+
if (text("filePath") !== undefined) {
|
|
655
|
+
result = call("read", { path: path("filePath"), offset: count("offset"), limit: count("limit") });
|
|
656
|
+
}
|
|
657
|
+
break;
|
|
658
|
+
case "write":
|
|
659
|
+
if (text("filePath") !== undefined && text("content") !== undefined) {
|
|
660
|
+
result = call("write", { path: path("filePath"), content: text("content") });
|
|
661
|
+
}
|
|
662
|
+
break;
|
|
663
|
+
case "edit":
|
|
664
|
+
// Pi edits need one unique, non-empty match: no replaceAll or create-by-edit.
|
|
665
|
+
if (text("filePath") !== undefined && text("oldString") && text("newString") !== undefined &&
|
|
666
|
+
args.replaceAll !== true) {
|
|
667
|
+
result = call("edit", {
|
|
668
|
+
path: path("filePath"),
|
|
669
|
+
edits: [{ oldText: text("oldString"), newText: text("newString") }],
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
break;
|
|
673
|
+
case "grep":
|
|
674
|
+
if (text("pattern") !== undefined) {
|
|
675
|
+
result = call("grep", { pattern: text("pattern"), path: path("path"), glob: text("include") });
|
|
676
|
+
}
|
|
677
|
+
break;
|
|
678
|
+
case "glob":
|
|
679
|
+
if (text("pattern") !== undefined) result = call("find", { pattern: text("pattern"), path: path("path") });
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
return result && allowedToolNames.has(result.name) ? result : undefined;
|
|
683
|
+
}
|
|
684
|
+
|
|
611
685
|
function safeJson(value: unknown): string {
|
|
612
686
|
try {
|
|
613
687
|
return JSON.stringify(value, null, 2);
|
|
@@ -1296,7 +1370,7 @@ You are the OpenCode side of a Pi Coding Agent bridge. Do not use OpenCode nativ
|
|
|
1296
1370
|
join(pluginsDir, "pi-native-tool-blocker.js"),
|
|
1297
1371
|
`export const PiNativeToolBlocker = async () => ({
|
|
1298
1372
|
"tool.execute.before": async (input) => {
|
|
1299
|
-
throw new Error("Native OpenCode tool
|
|
1373
|
+
throw new Error("Native OpenCode tool " + input.tool + " is disabled in the Pi bridge. Request the equivalent Pi tool instead: reply with <pi_tool_call>{\\"name\\":\\"tool_name\\",\\"arguments\\":{}}</pi_tool_call> using a tool from the Pi tool list.")
|
|
1300
1374
|
},
|
|
1301
1375
|
})
|
|
1302
1376
|
`,
|
|
@@ -1331,6 +1405,12 @@ export function streamOpenCode(
|
|
|
1331
1405
|
let stdoutRemainder = "";
|
|
1332
1406
|
let capturedSessionId: string | undefined;
|
|
1333
1407
|
let opencodeToolUse: string | undefined;
|
|
1408
|
+
// Text after the last blocked native call shows whether the model recovered in-run.
|
|
1409
|
+
let nativeTextMark = 0;
|
|
1410
|
+
let stepNativeCalls: (ParsedToolCall | undefined)[] = [];
|
|
1411
|
+
let nativeCalls: ParsedToolCall[] | undefined;
|
|
1412
|
+
// Thrown from the event handler so the process runner stops OpenCode.
|
|
1413
|
+
const nativeStepEnd = new Error("OpenCode stopped after a translated native step");
|
|
1334
1414
|
let opencodeError: string | undefined;
|
|
1335
1415
|
let finishReason: "stop" | "length" = "stop";
|
|
1336
1416
|
// Per-Pi-session reuse key supplied by Pi core (stable for the life of
|
|
@@ -1351,6 +1431,9 @@ export function streamOpenCode(
|
|
|
1351
1431
|
tools: getCurrentTools(messages),
|
|
1352
1432
|
};
|
|
1353
1433
|
const transcriptParts = serializedTranscriptParts(context.messages);
|
|
1434
|
+
const allowedToolNames = new Set(
|
|
1435
|
+
context.tools?.map((tool) => tool.name) ?? [],
|
|
1436
|
+
);
|
|
1354
1437
|
stream.push({ type: "start", partial: output });
|
|
1355
1438
|
if (options?.signal?.aborted) throw new Error("Request was aborted");
|
|
1356
1439
|
|
|
@@ -1381,6 +1464,7 @@ export function streamOpenCode(
|
|
|
1381
1464
|
return dir;
|
|
1382
1465
|
});
|
|
1383
1466
|
if (options?.signal?.aborted) throw new Error("Request was aborted");
|
|
1467
|
+
const projectDirs = [sessionDir, await realpath(sessionDir).catch(() => sessionDir)];
|
|
1384
1468
|
const images = imageContentsForModel(
|
|
1385
1469
|
sentMessages,
|
|
1386
1470
|
model.input.includes("image"),
|
|
@@ -1410,7 +1494,8 @@ export function streamOpenCode(
|
|
|
1410
1494
|
for (const path of imagePaths) args.push("--file", path);
|
|
1411
1495
|
|
|
1412
1496
|
const handleLine = async (line: string) => {
|
|
1413
|
-
|
|
1497
|
+
// Output after a translated native step belongs to a run Pi already ended.
|
|
1498
|
+
if (!line.trim() || nativeCalls) return;
|
|
1414
1499
|
let parsed: unknown;
|
|
1415
1500
|
try {
|
|
1416
1501
|
parsed = JSON.parse(line);
|
|
@@ -1449,26 +1534,42 @@ export function streamOpenCode(
|
|
|
1449
1534
|
finishReason = "length";
|
|
1450
1535
|
}
|
|
1451
1536
|
const tokens = objectRecord(part?.tokens);
|
|
1452
|
-
if (
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1537
|
+
if (tokens) {
|
|
1538
|
+
const cache = objectRecord(tokens.cache);
|
|
1539
|
+
output.usage.input = Number(tokens.input ?? 0);
|
|
1540
|
+
output.usage.output =
|
|
1541
|
+
Number(tokens.output ?? 0) + Number(tokens.reasoning ?? 0);
|
|
1542
|
+
output.usage.cacheRead = Number(cache?.read ?? 0);
|
|
1543
|
+
output.usage.cacheWrite = Number(cache?.write ?? 0);
|
|
1544
|
+
output.usage.totalTokens = Number(
|
|
1545
|
+
tokens.total ??
|
|
1546
|
+
output.usage.input +
|
|
1547
|
+
output.usage.output +
|
|
1548
|
+
output.usage.cacheRead +
|
|
1549
|
+
output.usage.cacheWrite,
|
|
1550
|
+
);
|
|
1551
|
+
calculateCost(model, output.usage);
|
|
1552
|
+
}
|
|
1553
|
+
const calls = stepNativeCalls;
|
|
1554
|
+
stepNativeCalls = [];
|
|
1555
|
+
// A fully translatable step ends the turn like a normal tool-call response.
|
|
1556
|
+
// Otherwise OpenCode continues and the model sees the blocker's correction.
|
|
1557
|
+
if (calls.length > 0 && calls.every(Boolean)) {
|
|
1558
|
+
nativeCalls = calls as ParsedToolCall[];
|
|
1559
|
+
throw nativeStepEnd;
|
|
1560
|
+
}
|
|
1467
1561
|
return;
|
|
1468
1562
|
}
|
|
1469
1563
|
|
|
1470
1564
|
if (event.type === "tool_use") {
|
|
1471
1565
|
opencodeToolUse = part?.tool ? String(part.tool) : "unknown";
|
|
1566
|
+
nativeTextMark = accumulatedText.length;
|
|
1567
|
+
stepNativeCalls.push(translateNativeToolCall(
|
|
1568
|
+
opencodeToolUse,
|
|
1569
|
+
objectRecord(part?.state)?.input,
|
|
1570
|
+
allowedToolNames,
|
|
1571
|
+
projectDirs,
|
|
1572
|
+
));
|
|
1472
1573
|
return;
|
|
1473
1574
|
}
|
|
1474
1575
|
|
|
@@ -1514,22 +1615,24 @@ export function streamOpenCode(
|
|
|
1514
1615
|
for (const line of lines) await handleLine(line);
|
|
1515
1616
|
},
|
|
1516
1617
|
onEnd: () => handleLine(stdoutRemainder),
|
|
1618
|
+
}).catch((error: unknown) => {
|
|
1619
|
+
// Stopping OpenCode after a translated native step is a normal end of turn.
|
|
1620
|
+
if (error === nativeStepEnd) return undefined;
|
|
1621
|
+
throw error;
|
|
1517
1622
|
});
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1623
|
+
if (result) {
|
|
1624
|
+
stderr = (stderr + result.stderr).slice(-STDERR_LIMIT);
|
|
1625
|
+
if (result.code !== 0) {
|
|
1626
|
+
throw new Error(stderr.trim() || `opencode exited with code ${result.code}`);
|
|
1627
|
+
}
|
|
1522
1628
|
}
|
|
1523
1629
|
if (opencodeError) throw new Error(opencodeError);
|
|
1524
|
-
if (opencodeToolUse) {
|
|
1630
|
+
if (opencodeToolUse && !nativeCalls && !accumulatedText.slice(nativeTextMark).trim()) {
|
|
1525
1631
|
throw new Error(
|
|
1526
1632
|
`OpenCode attempted to use its disabled native tool (${JSON.stringify(opencodeToolUse)}). Retry the request; Pi tools must be requested with <pi_tool_call>{"name":"...","arguments":{}}</pi_tool_call> markers.`,
|
|
1527
1633
|
);
|
|
1528
1634
|
}
|
|
1529
1635
|
|
|
1530
|
-
const allowedToolNames = new Set(
|
|
1531
|
-
context.tools?.map((tool) => tool.name) ?? [],
|
|
1532
|
-
);
|
|
1533
1636
|
const toolCallResult = parseToolCallResponse(
|
|
1534
1637
|
accumulatedText,
|
|
1535
1638
|
allowedToolNames,
|
|
@@ -1539,7 +1642,7 @@ export function streamOpenCode(
|
|
|
1539
1642
|
toolCallRejectionMessage(toolCallResult, allowedToolNames),
|
|
1540
1643
|
);
|
|
1541
1644
|
}
|
|
1542
|
-
const toolCalls = toolCallResult.calls;
|
|
1645
|
+
const toolCalls = [...toolCallResult.calls, ...(nativeCalls ?? [])];
|
|
1543
1646
|
// Unrecoverable marker text is stripped so it never leaks into the
|
|
1544
1647
|
// visible chat response; remaining prose still displays normally.
|
|
1545
1648
|
const displayText = toolCallResult.cleanedText ?? accumulatedText;
|
|
@@ -1585,7 +1688,8 @@ export function streamOpenCode(
|
|
|
1585
1688
|
);
|
|
1586
1689
|
// The turn succeeded: record the session for reuse, or delete the
|
|
1587
1690
|
// one-shot session record so it cannot accumulate (#71, #75).
|
|
1588
|
-
|
|
1691
|
+
// A session holding a blocked native call cannot be continued as a transcript prefix.
|
|
1692
|
+
if (piState && !payloadHook && !nativeCalls) {
|
|
1589
1693
|
piState.opencodeSessionId =
|
|
1590
1694
|
capturedSessionId ?? piState.opencodeSessionId;
|
|
1591
1695
|
piState.modelId = model.id;
|
|
@@ -1593,6 +1697,7 @@ export function streamOpenCode(
|
|
|
1593
1697
|
piState.systemPrompt = context.systemPrompt ?? "";
|
|
1594
1698
|
piState.sentParts = transcriptParts;
|
|
1595
1699
|
} else {
|
|
1700
|
+
if (piState) dropOpencodeSession(piState);
|
|
1596
1701
|
deleteOpenCodeSession(capturedSessionId);
|
|
1597
1702
|
}
|
|
1598
1703
|
|
|
@@ -1800,6 +1905,13 @@ export default async function opencodePiExtension(pi: ExtensionAPI) {
|
|
|
1800
1905
|
`opencode-pi: registered ${registeredModels.length} OpenCode CLI model(s). Use /model and pick ${PROVIDER_ID}.`,
|
|
1801
1906
|
"info",
|
|
1802
1907
|
);
|
|
1908
|
+
const version = /\d+\.\d+\.\d+/.exec(cliStatus.summary)?.[0];
|
|
1909
|
+
if (version !== VERIFIED_OPENCODE_VERSION) {
|
|
1910
|
+
ctx.ui.notify(
|
|
1911
|
+
`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.`,
|
|
1912
|
+
"warning",
|
|
1913
|
+
);
|
|
1914
|
+
}
|
|
1803
1915
|
if (lastDiscoveryError) {
|
|
1804
1916
|
ctx.ui.notify(
|
|
1805
1917
|
`opencode-pi: model discovery used fallback (${lastDiscoveryError})`,
|
package/src/process.ts
CHANGED
|
@@ -33,8 +33,11 @@ export async function runOpenCodeProcess(
|
|
|
33
33
|
const signalChild = (signal: NodeJS.Signals) => {
|
|
34
34
|
if (!child.pid) return;
|
|
35
35
|
try {
|
|
36
|
-
if (process.platform === "win32")
|
|
37
|
-
|
|
36
|
+
if (process.platform === "win32") {
|
|
37
|
+
// child.kill would stop only the launcher; /T also ends the native CLI it spawned.
|
|
38
|
+
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true })
|
|
39
|
+
.on("error", () => child.kill(signal));
|
|
40
|
+
} else process.kill(-child.pid, signal);
|
|
38
41
|
} catch { /* The child may already have exited. */ }
|
|
39
42
|
};
|
|
40
43
|
const stop = (error: Error) => {
|
|
@@ -59,7 +62,10 @@ export async function runOpenCodeProcess(
|
|
|
59
62
|
});
|
|
60
63
|
// Observe spawn failures immediately, even while a start callback is awaiting.
|
|
61
64
|
void closed.catch(() => undefined);
|
|
62
|
-
|
|
65
|
+
// A CLI that quits before reading its prompt explains why via stderr and exit code.
|
|
66
|
+
child.stdin?.on("error", (error: NodeJS.ErrnoException) => {
|
|
67
|
+
if (error.code !== "EPIPE") stop(error);
|
|
68
|
+
});
|
|
63
69
|
child.stdout.setEncoding("utf8");
|
|
64
70
|
child.stderr.setEncoding("utf8");
|
|
65
71
|
child.stderr.on("data", (chunk: string) => {
|