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
12 changes: 11 additions & 1 deletion bin/openpi.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,17 @@ const stop = () => {
};

try {
const jiti = createJiti(import.meta.url);
const bootstrap = createJiti(import.meta.url);
const { missingPiCodingAgentDiagnostic, resolveStandaloneJitiAliases } =
await bootstrap.import("../web/host/pi-coding-agent-entry.ts");
const aliases = resolveStandaloneJitiAliases({
fromUrl: import.meta.url,
});
if (!aliases["@earendil-works/pi-coding-agent"]) {
console.error(missingPiCodingAgentDiagnostic());
process.exit(1);
}
const jiti = createJiti(import.meta.url, { alias: aliases });
const [browserModule, hostModule, runtimeModule, statusModule, traceModule] =
await Promise.all([
jiti.import("../web/host/browser-launcher.ts"),
Expand Down
27 changes: 25 additions & 2 deletions extensions/web/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import type {
ExtensionAPI,
ExtensionCommandContext,
} from "@earendil-works/pi-coding-agent";
import {
missingPiCodingAgentDiagnostic,
PI_CODING_AGENT_ENTRY_ENV,
resolvePiCodingAgentEntry,
} from "../../web/host/pi-coding-agent-entry.ts";

const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000;

Expand All @@ -26,12 +31,20 @@ interface SpawnWebOptions {
stdio: "inherit";
}

function webProcessEnvironment(cwd: string) {
function webProcessEnvironment(
cwd: string,
piCodingAgentEntry: string | undefined,
) {
const environment: NodeJS.ProcessEnv = { ...process.env, PWD: cwd };
delete environment.OLDPWD;
delete environment.INIT_CWD;
delete environment.PI_SESSION_ID;
delete environment.PI_SESSION_FILE;
if (piCodingAgentEntry) {
environment[PI_CODING_AGENT_ENTRY_ENV] = piCodingAgentEntry;
} else {
delete environment[PI_CODING_AGENT_ENTRY_ENV];
}
return environment;
}

Expand All @@ -40,6 +53,7 @@ export interface WebCommandDependencies {
spawn(command: string, args: string[], options: SpawnWebOptions): WebProcess;
clearTerminal(): void;
holdParentSigint(): () => void;
resolvePiCodingAgentEntry(): string | undefined;
shutdownTimeoutMs: number;
}

Expand All @@ -65,6 +79,8 @@ const defaultDependencies: WebCommandDependencies = {
process.on("SIGINT", keepPiAlive);
return () => process.removeListener("SIGINT", keepPiAlive);
},
resolvePiCodingAgentEntry: () =>
resolvePiCodingAgentEntry({ source: "host" }),
shutdownTimeoutMs: DEFAULT_SHUTDOWN_TIMEOUT_MS,
};

Expand Down Expand Up @@ -95,6 +111,7 @@ function runWebInForeground(
dependencies: WebCommandDependencies,
setActive: (active: ActiveWebProcess | undefined) => void,
isShuttingDown: () => boolean,
piCodingAgentEntry: string,
) {
return ctx.ui.custom<WebExit>((tui, _theme, _keybindings, done) => {
let finished = false;
Expand Down Expand Up @@ -128,7 +145,7 @@ function runWebInForeground(
[dependencies.entrypoint, "web", "--no-workspace"],
{
cwd: childCwd,
env: webProcessEnvironment(childCwd),
env: webProcessEnvironment(childCwd, piCodingAgentEntry),
shell: false,
stdio: "inherit",
},
Expand Down Expand Up @@ -191,6 +208,11 @@ export default function web(
ctx.ui.notify("OpenPI Web Workbench is already running.", "warning");
return;
}
const piCodingAgentEntry = dependencies.resolvePiCodingAgentEntry();
if (!piCodingAgentEntry) {
ctx.ui.notify(missingPiCodingAgentDiagnostic(), "error");
return;
}

running = true;
try {
Expand All @@ -201,6 +223,7 @@ export default function web(
active = next;
},
() => shuttingDown,
piCodingAgentEntry,
);
if (shuttingDown) return;
if (result.kind === "error") {
Expand Down
59 changes: 58 additions & 1 deletion tests/extensions/web/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,12 @@ class FakeWebProcess extends EventEmitter implements WebProcess {
}

function harness(
options: { mode?: "tui" | "print"; idle?: boolean; stopError?: Error } = {},
options: {
mode?: "tui" | "print";
idle?: boolean;
stopError?: Error;
piCodingAgentEntry?: string | null;
} = {},
) {
const hooks = new Map<string, Array<(event: unknown) => unknown>>();
let command: CommandHandler | undefined;
Expand All @@ -52,10 +57,12 @@ function harness(
let started = 0;
let rendered = 0;
let spawnCalls = 0;
let resolveCalls = 0;
let activeSigint = 0;
let clearCalls = 0;
const notifications: Array<{ message: string; level?: string }> = [];
const children: FakeWebProcess[] = [];
const spawnEnvs: NodeJS.ProcessEnv[] = [];
const cwd = "/workspace/current";
const pi = {
registerCommand(name: string, definition: { handler: CommandHandler }) {
Expand All @@ -71,6 +78,7 @@ function harness(
entrypoint: "/package/bin/openpi.js",
spawn(commandName, args, spawnOptions) {
spawnCalls++;
spawnEnvs.push(spawnOptions.env);
assert.equal(commandName, process.execPath);
assert.deepEqual(args, [
"/package/bin/openpi.js",
Expand All @@ -87,6 +95,13 @@ function harness(
([name]) => name.toLowerCase() === "path",
)?.[1];
assert.equal(childPath, process.env.PATH);
assert.equal(
spawnOptions.env.OPENPI_PI_CODING_AGENT_ENTRY,
options.piCodingAgentEntry === null
? undefined
: (options.piCodingAgentEntry ??
"/host/pi-coding-agent/dist/index.js"),
);
assert.equal(spawnOptions.shell, false);
assert.equal(spawnOptions.stdio, "inherit");
const child = new FakeWebProcess();
Expand All @@ -102,6 +117,12 @@ function harness(
activeSigint--;
};
},
resolvePiCodingAgentEntry: () => {
resolveCalls++;
return options.piCodingAgentEntry === null
? undefined
: (options.piCodingAgentEntry ?? "/host/pi-coding-agent/dist/index.js");
},
shutdownTimeoutMs: 20,
};

Expand Down Expand Up @@ -155,11 +176,13 @@ function harness(
emit,
children,
notifications,
spawnEnv: () => spawnEnvs.at(-1),
customCalls: () => customCalls,
stopped: () => stopped,
started: () => started,
rendered: () => rendered,
spawnCalls: () => spawnCalls,
resolveCalls: () => resolveCalls,
activeSigint: () => activeSigint,
clearCalls: () => clearCalls,
};
Expand All @@ -176,6 +199,7 @@ test("/web hands the terminal to the exact packaged Web CLI and restores Pi", as
await new Promise((resolve) => setImmediate(resolve));

assert.equal(h.spawnCalls(), 1);
assert.equal(h.resolveCalls(), 1);
assert.equal(h.stopped(), 1);
assert.equal(h.clearCalls(), 1);
assert.equal(h.activeSigint(), 1);
Expand All @@ -199,6 +223,39 @@ test("/web hands the terminal to the exact packaged Web CLI and restores Pi", as
}
});

test("/web hands the host Pi entry to the child and fail-closes without one", async () => {
const previousEntry = process.env.OPENPI_PI_CODING_AGENT_ENTRY;
process.env.OPENPI_PI_CODING_AGENT_ENTRY = "/stale/not-a-pi-package.js";
const resolvedEntry =
"/pi/node_modules/@earendil-works/pi-coding-agent/dist/index.js";
try {
const resolved = harness({ piCodingAgentEntry: resolvedEntry });
const running = resolved.run();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(resolved.resolveCalls(), 1);
assert.equal(
resolved.spawnEnv()?.OPENPI_PI_CODING_AGENT_ENTRY,
resolvedEntry,
);
resolved.children[0]!.close(0);
await running;

const unresolved = harness({ piCodingAgentEntry: null });
await unresolved.run();
assert.equal(unresolved.spawnCalls(), 0);
assert.equal(unresolved.resolveCalls(), 1);
assert.match(
unresolved.notifications.at(-1)?.message ?? "",
/could not resolve @earendil-works\/pi-coding-agent/u,
);
assert.equal(unresolved.notifications.at(-1)?.level, "error");
} finally {
if (previousEntry === undefined)
delete process.env.OPENPI_PI_CODING_AGENT_ENTRY;
else process.env.OPENPI_PI_CODING_AGENT_ENTRY = previousEntry;
}
});

test("/web rejects unsupported modes, arguments, busy sessions, and duplicates", async () => {
const print = harness({ mode: "print" });
await print.run();
Expand Down
Loading
Loading