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

try {
const jiti = createJiti(import.meta.url);
const piCodingAgentEntry = process.env.OPENPI_PI_CODING_AGENT_ENTRY;
const jiti = createJiti(
import.meta.url,
piCodingAgentEntry
? { alias: { "@earendil-works/pi-coding-agent": piCodingAgentEntry } }
: {},
);
const [browserModule, hostModule, runtimeModule, statusModule, traceModule] =
await Promise.all([
jiti.import("../web/host/browser-launcher.ts"),
Expand Down
59 changes: 56 additions & 3 deletions extensions/web/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { spawn as nodeSpawn } from "node:child_process";
import { dirname } from "node:path";
import { existsSync, readFileSync, realpathSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type {
ExtensionAPI,
ExtensionCommandContext,
} from "@earendil-works/pi-coding-agent";

const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000;
const PI_CODING_AGENT_PACKAGE = "@earendil-works/pi-coding-agent";
const PI_CODING_AGENT_ENTRY_ENV = "OPENPI_PI_CODING_AGENT_ENTRY";
const PACKAGE_ROOT_SEARCH_DEPTH = 10;

export interface WebProcess {
readonly exitCode: number | null;
Expand All @@ -26,12 +30,56 @@ interface SpawnWebOptions {
stdio: "inherit";
}

function webProcessEnvironment(cwd: string) {
function findPackageRoot(realPath: string, packageName: string) {
let dir = dirname(realPath);
for (let depth = 0; depth < PACKAGE_ROOT_SEARCH_DEPTH; depth++) {
const manifestPath = join(dir, "package.json");
if (existsSync(manifestPath)) {
const manifest: { name?: unknown } = JSON.parse(
readFileSync(manifestPath, "utf8"),
);
if (manifest.name === packageName) return dir;
}
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
return undefined;
}

// Pi loads this extension through jiti aliases, so neither `import.meta.resolve`
// nor `createRequire` can locate the peer package here; the launcher path is the
// only handle that reaches the running Pi installation.
function resolvePiCodingAgentEntryFromLauncher() {
const launcher = process.argv[1];
if (!launcher) return undefined;
try {
const packageRoot = findPackageRoot(
realpathSync(launcher),
PI_CODING_AGENT_PACKAGE,
);
if (!packageRoot) return undefined;
const entry = join(packageRoot, "dist", "index.js");
return existsSync(entry) ? entry : undefined;
} catch {
return undefined;
}
}

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 +88,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 +114,7 @@ const defaultDependencies: WebCommandDependencies = {
process.on("SIGINT", keepPiAlive);
return () => process.removeListener("SIGINT", keepPiAlive);
},
resolvePiCodingAgentEntry: resolvePiCodingAgentEntryFromLauncher,
shutdownTimeoutMs: DEFAULT_SHUTDOWN_TIMEOUT_MS,
};

Expand Down Expand Up @@ -128,7 +178,10 @@ function runWebInForeground(
[dependencies.entrypoint, "web", "--no-workspace"],
{
cwd: childCwd,
env: webProcessEnvironment(childCwd),
env: webProcessEnvironment(
childCwd,
dependencies.resolvePiCodingAgentEntry(),
),
shell: false,
stdio: "inherit",
},
Expand Down
47 changes: 46 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;
} = {},
) {
const hooks = new Map<string, Array<(event: unknown) => unknown>>();
let command: CommandHandler | undefined;
Expand All @@ -56,6 +61,7 @@ function harness(
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 +77,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 @@ -84,6 +91,10 @@ function harness(
assert.equal(spawnOptions.env.PI_SESSION_ID, undefined);
assert.equal(spawnOptions.env.PI_SESSION_FILE, undefined);
assert.equal(spawnOptions.env.PATH, process.env.PATH);
assert.equal(
spawnOptions.env.OPENPI_PI_CODING_AGENT_ENTRY,
options.piCodingAgentEntry,
);
assert.equal(spawnOptions.shell, false);
assert.equal(spawnOptions.stdio, "inherit");
const child = new FakeWebProcess();
Expand All @@ -99,6 +110,7 @@ function harness(
activeSigint--;
};
},
resolvePiCodingAgentEntry: () => options.piCodingAgentEntry,
shutdownTimeoutMs: 20,
};

Expand Down Expand Up @@ -152,6 +164,7 @@ function harness(
emit,
children,
notifications,
spawnEnv: () => spawnEnvs.at(-1),
customCalls: () => customCalls,
stopped: () => stopped,
started: () => started,
Expand Down Expand Up @@ -196,6 +209,38 @@ test("/web hands the terminal to the exact packaged Web CLI and restores Pi", as
}
});

test("/web hands the child the resolved Pi entry and drops a stale one", async () => {
const previousEntry = process.env.OPENPI_PI_CODING_AGENT_ENTRY;
process.env.OPENPI_PI_CODING_AGENT_ENTRY =
"/stale/pi-coding-agent/dist/index.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.spawnEnv()?.OPENPI_PI_CODING_AGENT_ENTRY,
resolvedEntry,
);
resolved.children[0]!.close(0);
await running;

const unresolved = harness();
const failed = unresolved.run();
await new Promise((resolve) => setImmediate(resolve));
const childEnv = unresolved.spawnEnv();
assert.ok(childEnv);
assert.equal("OPENPI_PI_CODING_AGENT_ENTRY" in childEnv, false);
unresolved.children[0]!.close(1);
await failed;
} 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
76 changes: 76 additions & 0 deletions tests/web/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,3 +228,79 @@ export class PiWebRuntime {
await rm(temporaryRoot, { recursive: true, force: true });
}
});

test("installed CLI aliases the Pi peer package to the handed-over entry", async () => {
const temporaryRoot = await mkdtemp(join(process.cwd(), ".openpi-cli-test-"));
const packageRoot = join(temporaryRoot, "node_modules", "@tt-a1i", "openpi");
try {
await mkdir(join(packageRoot, "bin"), { recursive: true });
await mkdir(join(packageRoot, "web", "host"), { recursive: true });
await mkdir(join(packageRoot, "web", "runtime"), { recursive: true });
await cp(entrypointPath, join(packageRoot, "bin", "openpi.js"));
await writeFile(
join(packageRoot, "package.json"),
JSON.stringify({ type: "module" }),
);
const stubEntry = join(packageRoot, "pi-entry-stub.js");
await writeFile(stubEntry, 'export const PI_ENTRY_STUB = "handed-over";\n');
await writeFile(
join(packageRoot, "web", "host", "browser-launcher.ts"),
"export async function openBrowser(): Promise<boolean> { return false; }\n",
);
await writeFile(
join(packageRoot, "web", "host", "terminal-status.ts"),
"export function formatWebReadyScreen(options: { origin: string; url: string }): string { return `ready ${options.origin} ${options.url}`; }\n",
);
await writeFile(
join(packageRoot, "web", "host", "web-host.ts"),
`export class WebHost {
origin = "http://127.0.0.1:12346";
url = "http://127.0.0.1:12346/";
async start(): Promise<void> {}
async stop(): Promise<void> {}
}\n`,
);
await writeFile(
join(packageRoot, "web", "trace.ts"),
"export function traceWeb(): void {}\n",
);
await writeFile(
join(packageRoot, "web", "runtime", "pi-runtime.ts"),
`import { writeFile } from "node:fs/promises";
import { PI_ENTRY_STUB } from "@earendil-works/pi-coding-agent";

export class PiWebRuntime {
static async createWithoutWorkspace(): Promise<{ cwd: string; dispose(): Promise<void> }> {
const marker = process.env.OPENPI_CLI_PI_ENTRY_MARKER;
if (marker) await writeFile(marker, PI_ENTRY_STUB);
return {
cwd: "/web-owned-bootstrap",
async dispose(): Promise<void> {},
};
}
}\n`,
);

const entryMarker = join(temporaryRoot, "pi-entry");
const { stdout } = await execFileAsync(
process.execPath,
[
join(packageRoot, "bin", "openpi.js"),
"web",
"--no-workspace",
"--no-open",
],
{
env: {
...process.env,
OPENPI_PI_CODING_AGENT_ENTRY: stubEntry,
OPENPI_CLI_PI_ENTRY_MARKER: entryMarker,
},
},
);
assert.match(stdout, /ready http:\/\/127\.0\.0\.1:12346/u);
assert.equal(await readFile(entryMarker, "utf8"), "handed-over");
} finally {
await rm(temporaryRoot, { recursive: true, force: true });
}
});
Loading