Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"@earendil-works/pi-coding-agent": "0.78.0",
"@effect/cli": "^0.75.1",
"@effect/platform": "^0.96.1",
"@effect/platform-bun": "^0.89.0",
"@effect/platform-node": "^0.106.0",
"@effect/printer": "^0.49.0",
"@effect/printer-ansi": "^0.49.0",
"@inquirer/prompts": "^8.4.3",
Expand Down
4 changes: 2 additions & 2 deletions apps/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { FileSystem, Terminal } from "@effect/platform";
import { BunPath } from "@effect/platform-bun";
import { NodePath } from "@effect/platform-node";
import { Console, Effect, Exit, Layer } from "effect";
import type { AuthOutcome } from "../agent/auth";
import type { ProposeOutput } from "../agent/propose";
Expand Down Expand Up @@ -182,7 +182,7 @@ const run = async (
makeTestTerminal(stdoutChunks),
makeTestStderr(stderrChunks),
makeTestConsole(stdoutChunks, stderrChunks),
BunPath.layer,
NodePath.layer,
Layer.succeed(FileSystem.FileSystem, {} as FileSystem.FileSystem)
);
const exit = await Effect.runPromiseExit(
Expand Down
4 changes: 2 additions & 2 deletions apps/cli/src/__tests__/propose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
import { Effect } from "effect";
import { buildUserPrompt, propose } from "../agent/propose";
import { buildPiAuth, hasUsableAuth } from "../agent/provider";
import { makeBunApplyRunner } from "../agent/tools/apply-command";
import { makeNodeApplyRunner } from "../agent/tools/apply-command";
import type { SubmitPlanDeps } from "../agent/tools/submit-plan";
import { skillsMetadata } from "../skills/registry";

Expand Down Expand Up @@ -50,7 +50,7 @@ describe("propose — pi session smoke", () => {
gameName: "Hades",
gate: { bypassConfirms: true, confirm: () => Promise.resolve(false) },
modelRegistry: auth.modelRegistry,
runApply: makeBunApplyRunner(),
runApply: makeNodeApplyRunner(),
skills: skillsMetadata.filter((s) => s.name !== "funfa"),
submitPlanDeps,
webSearch: () => Promise.resolve({ results: [] }),
Expand Down
8 changes: 4 additions & 4 deletions apps/cli/src/__tests__/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import {
type ExecResult,
makeApplyCommandTool,
makeBunApplyRunner,
makeNodeApplyRunner,
} from "../agent/tools/apply-command";
import {
type LoadSkillResult,
Expand Down Expand Up @@ -142,15 +142,15 @@ describe("apply_command tool", () => {
});
});

describe("makeBunApplyRunner", () => {
describe("makeNodeApplyRunner", () => {
test("runs a real command", async () => {
const result = await makeBunApplyRunner()(["echo", "hello"]);
const result = await makeNodeApplyRunner()(["echo", "hello"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("hello");
});

test("maps a missing binary to exit code 127", async () => {
const result = await makeBunApplyRunner()([
const result = await makeNodeApplyRunner()([
"funfa-definitely-not-a-real-binary-xyz",
]);
expect(result.exitCode).toBe(127);
Expand Down
64 changes: 45 additions & 19 deletions apps/cli/src/agent/tools/apply-command.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { execFile } from "node:child_process";
import {
defineTool,
type ToolDefinition,
Expand Down Expand Up @@ -30,27 +31,52 @@ const isEnoent = (err: unknown): boolean =>
err !== null &&
(err as { code?: string }).code === "ENOENT";

/** Bun-backed argv runner. Preserves the ENOENT→127 "command absent" contract. */
export const makeBunApplyRunner = (): ApplyRunner => async (argv) => {
try {
const proc = Bun.spawn([...argv], { stderr: "pipe", stdout: "pipe" });
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
const exitCode = (await proc.exited) ?? 0;
return { exitCode, stderr, stdout };
} catch (err) {
if (isEnoent(err)) {
return {
exitCode: 127,
stderr: `command not found: ${argv.join(" ")}`,
/** Max captured stdout/stderr per apply step (downloads write to files via -o). */
const APPLY_MAX_BUFFER = 64 * 1024 * 1024;

/**
* Node-backed argv runner (uses `node:child_process`, so it runs on Node and
* Bun alike). `execFile` takes an argv array with no shell — matching the
* tool's no-shell contract — and preserves the ENOENT→127 "command absent"
* signal plus the child's real exit code.
*/
export const makeNodeApplyRunner = (): ApplyRunner => (argv) =>
new Promise<ExecResult>((resolve) => {
const [command, ...args] = argv;
if (command === undefined) {
resolve({
exitCode: 1,
stderr: "apply_command: argv must not be empty",
stdout: "",
};
});
return;
}
return { exitCode: 1, stderr: String(err), stdout: "" };
}
};
execFile(
command,
args,
{ encoding: "utf8", maxBuffer: APPLY_MAX_BUFFER },
(err, stdout, stderr) => {
if (err === null) {
resolve({ exitCode: 0, stderr, stdout });
return;
}
if (isEnoent(err)) {
resolve({
exitCode: 127,
stderr: `command not found: ${argv.join(" ")}`,
stdout: "",
});
return;
}
const code = (err as { code?: unknown }).code;
resolve({
exitCode: typeof code === "number" ? code : 1,
stderr: stderr.length > 0 ? stderr : String(err),
stdout,
});
}
);
});

/**
* Build the `apply_command` tool. The permission gate (see `agent/permission.ts`)
Expand Down
23 changes: 13 additions & 10 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { homedir } from "node:os";
import { join } from "node:path";

import { getAgentDir } from "@earendil-works/pi-coding-agent";
import { BunContext, BunRuntime } from "@effect/platform-bun";
import { NodeContext, NodeRuntime } from "@effect/platform-node";
import { confirm, input, select } from "@inquirer/prompts";
import { Effect, Layer } from "effect";
import { type AuthIO, listAuthProviders, openUrl, runAuth } from "./agent/auth";
Expand All @@ -14,7 +14,7 @@ import {
MissingProviderKeysError,
resolveModelOverride,
} from "./agent/provider";
import { makeBunApplyRunner } from "./agent/tools/apply-command";
import { makeNodeApplyRunner } from "./agent/tools/apply-command";
import { makeBunFetchPage } from "./agent/tools/fetch-page";
import type { SubmitPlanDeps } from "./agent/tools/submit-plan";
import { makeParallelWebSearch } from "./agent/tools/web-search";
Expand All @@ -23,7 +23,7 @@ import { runCli } from "./core";
import { type Deps, layerFromDeps } from "./core/deps";
import { listExistingPlans, writePlan as writePlanToDisk } from "./core/plan";
import { isUserAbort, reviewPlanInteractive } from "./core/review-plan";
import { StderrBun } from "./services/stderr";
import { StderrNode } from "./services/stderr";
import { skillsMetadata } from "./skills/registry";

const agentDir = getAgentDir();
Expand All @@ -41,7 +41,7 @@ const bunWebSearch = makeSubscriptionWebSearch({
authStorage: piAuth.authStorage,
fallback: makeParallelWebSearch({ apiKey: process.env.PARALLEL_API_KEY }),
});
const bunApplyRunner = makeBunApplyRunner();
const applyRunner = makeNodeApplyRunner();
const siblingSkills = skillsMetadata.filter((s) => s.name !== "funfa");

/** Interactive consent for a PHASE 4 apply step. Default deny (Enter declines). */
Expand Down Expand Up @@ -160,7 +160,7 @@ const realDeps: Deps = {
gameName,
gate: { bypassConfirms, confirm: applyConfirm },
modelRegistry,
runApply: bunApplyRunner,
runApply: applyRunner,
skills: siblingSkills,
submitPlanDeps: buildSubmitPlanDeps(bypassConfirms),
webSearch: bunWebSearch,
Expand Down Expand Up @@ -203,10 +203,13 @@ const realDeps: Deps = {

const AppLayer = Layer.mergeAll(
layerFromDeps(realDeps),
StderrBun,
BunContext.layer
StderrNode,
NodeContext.layer
);

BunRuntime.runMain(runCli(Bun.argv.slice(2)).pipe(Effect.provide(AppLayer)), {
disableErrorReporting: true,
});
NodeRuntime.runMain(
runCli(process.argv.slice(2)).pipe(Effect.provide(AppLayer)),
{
disableErrorReporting: true,
}
);
2 changes: 1 addition & 1 deletion apps/cli/src/services/stderr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export interface Stderr {

export class StderrTag extends Context.Tag("cli/Stderr")<StderrTag, Stderr>() {}

export const StderrBun: Layer.Layer<StderrTag> = Layer.succeed(StderrTag, {
export const StderrNode: Layer.Layer<StderrTag> = Layer.succeed(StderrTag, {
write: (s) =>
Effect.sync(() => {
process.stderr.write(s);
Expand Down
8 changes: 6 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading