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
33 changes: 33 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,37 @@ jobs:
# 3. annotate: exercises annotate server startup with a real file.
Test-PlannotatorServer "plannotator annotate" "19501" "/api/plan" @("annotate", "README.md")

pi-extension-ai-runtime-windows:
needs: test
# Exercises the Pi extension's Node/jiti server mirror on Windows with an
# npm-style `pi` shim pair. The binary smoke above covers the compiled Bun
# CLI, but the published Pi extension uses this separate Node path.
name: Pi extension AI runtime (Windows)
runs-on: windows-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.11

- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 24

- name: Install dependencies
run: bun install

- name: Generate Pi extension shared copies
shell: bash
run: bash apps/pi-extension/vendor.sh

- name: Build Pi AI runtime smoke
run: bun build scripts/smoke-pi-extension-ai-runtime.ts --target=node --outfile "$env:RUNNER_TEMP/pi-ai-runtime-smoke.mjs"

- name: Run Pi AI runtime smoke
run: node "$env:RUNNER_TEMP/pi-ai-runtime-smoke.mjs"

install-script-smoke:
needs: build
runs-on: ${{ matrix.os }}
Expand Down Expand Up @@ -423,6 +454,7 @@ jobs:
needs:
- build
- smoke-binaries
- pi-extension-ai-runtime-windows
- install-script-smoke
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
Expand Down Expand Up @@ -489,6 +521,7 @@ jobs:
needs:
- build
- smoke-binaries
- pi-extension-ai-runtime-windows
- install-script-smoke
runs-on: ubuntu-latest
permissions:
Expand Down
31 changes: 31 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,37 @@ jobs:
- name: Run tests
run: bun test

pi-extension-ai-runtime-windows:
# Exercises the Pi extension's Node/jiti server mirror on Windows with an
# npm-style `pi` shim pair. This catches regressions where `where pi`
# resolves the extensionless shim before pi.cmd and the Ask AI provider
# crashes before the plan review UI opens.
name: Pi extension AI runtime (Windows)
runs-on: windows-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest

- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 24

- name: Install dependencies
run: bun install

- name: Generate Pi extension shared copies
shell: bash
run: bash apps/pi-extension/vendor.sh

- name: Build Pi AI runtime smoke
run: bun build scripts/smoke-pi-extension-ai-runtime.ts --target=node --outfile "$env:RUNNER_TEMP/pi-ai-runtime-smoke.mjs"

- name: Run Pi AI runtime smoke
run: node "$env:RUNNER_TEMP/pi-ai-runtime-smoke.mjs"

install-cmd-windows:
# End-to-end integration test for scripts/install.cmd on real cmd.exe.
# The unit tests in scripts/install.test.ts are file-content string checks
Expand Down
6 changes: 2 additions & 4 deletions apps/pi-extension/server/ai-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream";

import { resolveCommandFromWhichOutput } from "../generated/ai/providers/command-path.js";
import { json, toWebRequest } from "./helpers.js";

export interface PiAIRuntime {
Expand All @@ -21,10 +22,7 @@ function whichCmd(cmd: string): string | null {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
});
return output
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? null;
return resolveCommandFromWhichOutput(output);
} catch {
return null;
}
Expand Down
2 changes: 1 addition & 1 deletion apps/pi-extension/vendor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ for f in index types provider session-manager endpoints context base-session; do
printf '// @generated — DO NOT EDIT. Source: packages/ai/%s.ts\n' "$f" | cat - "$src" > "generated/ai/$f.ts"
done

for f in claude-agent-sdk codex-sdk opencode-sdk pi-sdk pi-sdk-node pi-events; do
for f in claude-agent-sdk codex-sdk opencode-sdk command-path pi-sdk pi-sdk-node pi-events; do
src="../../packages/ai/providers/$f.ts"
printf '// @generated — DO NOT EDIT. Source: packages/ai/providers/%s.ts\n' "$f" | cat - "$src" > "generated/ai/providers/$f.ts"
done
111 changes: 111 additions & 0 deletions packages/ai/ai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ import type {
AIMessage,
AIContext,
} from "./types.ts";
import {
buildWindowsCommandScriptSpawnCommand,
killWindowsProcessTree,
resolveCommandFromWhichOutput,
resolveWindowsCommandShim,
shouldSpawnViaShell,
} from "./providers/command-path.ts";

// ---------------------------------------------------------------------------
// Helpers — mock provider/session for testing
Expand Down Expand Up @@ -71,6 +78,110 @@ function mockProvider(name = "mock"): AIProvider {
};
}

// ---------------------------------------------------------------------------
// Command path helpers
// ---------------------------------------------------------------------------

describe("command path helpers", () => {
test("resolveWindowsCommandShim prefers a sibling .cmd for npm shims", () => {
const raw = String.raw`C:\Users\Andrew\AppData\Roaming\npm\pi`;
const resolved = resolveWindowsCommandShim(
raw,
"win32",
(path) => path === `${raw}.cmd`,
);

expect(resolved).toBe(`${raw}.cmd`);
});

test("resolveCommandFromWhichOutput skips extensionless Windows shims", () => {
const raw = String.raw`C:\Users\Andrew\AppData\Roaming\npm\pi`;
const resolved = resolveCommandFromWhichOutput(
`${raw}\r\n${raw}.cmd\r\n`,
"win32",
() => false,
);

expect(resolved).toBe(`${raw}.cmd`);
});

test("resolveCommandFromWhichOutput preserves the first non-Windows result", () => {
expect(
resolveCommandFromWhichOutput("/usr/local/bin/pi\n/usr/bin/pi\n", "darwin"),
).toBe("/usr/local/bin/pi");
});

test("shouldSpawnViaShell only flags Windows command scripts", () => {
expect(
shouldSpawnViaShell(
String.raw`C:\Users\Andrew\AppData\Roaming\npm\pi.cmd`,
"win32",
),
).toBe(true);
expect(shouldSpawnViaShell(String.raw`C:\tools\pi.exe`, "win32")).toBe(false);
expect(shouldSpawnViaShell("/usr/local/bin/pi.cmd", "darwin")).toBe(false);
});

test("buildWindowsCommandScriptSpawnCommand wraps command scripts for Bun.spawn", () => {
const command = buildWindowsCommandScriptSpawnCommand(
String.raw`C:\Users\Andrew Ramos\AppData\Roaming\npm\pi.cmd`,
["--mode", "rpc"],
"win32",
String.raw`C:\Windows\System32\cmd.exe`,
);

expect(command).toEqual([
String.raw`C:\Windows\System32\cmd.exe`,
"/d",
"/s",
"/c",
String.raw`"C:\Users\Andrew Ramos\AppData\Roaming\npm\pi.cmd" --mode rpc`,
]);
});

test("buildWindowsCommandScriptSpawnCommand ignores native executables", () => {
expect(
buildWindowsCommandScriptSpawnCommand(
String.raw`C:\tools\pi.exe`,
["--mode", "rpc"],
"win32",
),
).toBeNull();
});

test("killWindowsProcessTree invokes taskkill with tree flags on Windows", () => {
const calls: Array<{
command: string;
args: string[];
options: { stdio: "ignore"; windowsHide: boolean };
}> = [];
const killed = killWindowsProcessTree(1234, "win32", (command, args, options) => {
calls.push({ command, args, options });
return { status: 0 };
});

expect(killed).toBe(true);
expect(calls).toEqual([
{
command: "taskkill",
args: ["/pid", "1234", "/t", "/f"],
options: { stdio: "ignore", windowsHide: true },
},
]);
});

test("killWindowsProcessTree skips non-Windows platforms", () => {
let called = false;
const killed = killWindowsProcessTree(1234, "darwin", () => {
called = true;
return { status: 0 };
});

expect(killed).toBe(false);
expect(called).toBe(false);
});
});

// ---------------------------------------------------------------------------
// SessionManager
// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions packages/ai/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"./endpoints": "./endpoints.ts",
"./providers/claude-agent-sdk": "./providers/claude-agent-sdk.ts",
"./providers/codex-sdk": "./providers/codex-sdk.ts",
"./providers/command-path": "./providers/command-path.ts",
"./providers/pi-sdk": "./providers/pi-sdk.ts",
"./providers/opencode-sdk": "./providers/opencode-sdk.ts",
"./providers/pi-sdk-node": "./providers/pi-sdk-node.ts"
Expand Down
115 changes: 115 additions & 0 deletions packages/ai/providers/command-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";

type Platform = NodeJS.Platform;
type ExistsFn = (path: string) => boolean;
type TaskkillFn = (
command: string,
args: string[],
options: { stdio: "ignore"; windowsHide: boolean },
) => { status: number | null; error?: Error };

const WINDOWS_EXECUTABLE_EXTENSIONS = [".cmd", ".exe", ".bat", ".com"] as const;
const WINDOWS_SHELL_EXTENSIONS = new Set([".cmd", ".bat"]);

function trimCommandPath(commandPath: string): string {
return commandPath.trim();
}

function getKnownWindowsExtension(commandPath: string): string | null {
const match = trimCommandPath(commandPath).match(/\.(cmd|exe|bat|com)$/i);
return match ? `.${match[1].toLowerCase()}` : null;
}

export function resolveWindowsCommandShim(
commandPath: string,
platform: Platform = process.platform,
exists: ExistsFn = existsSync,
): string {
const candidate = trimCommandPath(commandPath);
if (!candidate || platform !== "win32" || getKnownWindowsExtension(candidate)) {
return candidate;
}

for (const ext of WINDOWS_EXECUTABLE_EXTENSIONS) {
const pathWithExtension = `${candidate}${ext}`;
if (exists(pathWithExtension)) return pathWithExtension;
}

return candidate;
}

export function resolveCommandFromWhichOutput(
output: string,
platform: Platform = process.platform,
exists: ExistsFn = existsSync,
): string | null {
const candidates = output
.split(/\r?\n/)
.map(trimCommandPath)
.filter(Boolean);

if (candidates.length === 0) return null;
if (platform !== "win32") return candidates[0] ?? null;

for (const candidate of candidates) {
const resolved = resolveWindowsCommandShim(candidate, platform, exists);
if (getKnownWindowsExtension(resolved)) return resolved;
}

return resolveWindowsCommandShim(candidates[0] ?? "", platform, exists) || null;
}

export function shouldSpawnViaShell(
commandPath: string,
platform: Platform = process.platform,
): boolean {
if (platform !== "win32") return false;
const ext = getKnownWindowsExtension(commandPath);
return ext ? WINDOWS_SHELL_EXTENSIONS.has(ext) : false;
}

function quoteWindowsShellArg(arg: string): string {
if (!arg || /[\s"&()^|<>]/.test(arg)) {
return `"${arg.replace(/"/g, '\\"')}"`;
}
return arg;
}

export function buildWindowsCommandScriptSpawnCommand(
commandPath: string,
args: string[],
platform: Platform = process.platform,
comspec: string | undefined = process.env.ComSpec,
): string[] | null {
if (!shouldSpawnViaShell(commandPath, platform)) return null;

return [
comspec || "cmd.exe",
"/d",
"/s",
"/c",
[commandPath, ...args].map(quoteWindowsShellArg).join(" "),
];
}

export function killWindowsProcessTree(
pid: number | null | undefined,
platform: Platform = process.platform,
runTaskkill: TaskkillFn = spawnSync as TaskkillFn,
): boolean {
if (
platform !== "win32" ||
typeof pid !== "number" ||
!Number.isFinite(pid) ||
pid <= 0
) {
return false;
}

const result = runTaskkill("taskkill", ["/pid", String(pid), "/t", "/f"], {
stdio: "ignore",
windowsHide: true,
});
return !result.error && result.status === 0;
}
Loading
Loading