import { existsSync } from "node:fs"; import { basename } from "node:path"; import { spawn } from "node:child_process"; import type { BashOperations, ExtensionAPI } from "@earendil-works/pi-coding-agent"; function getPwshPath() { if (process.env.PI_USER_BASH_PWSH) return process.env.PI_USER_BASH_PWSH; if (process.env.PI_USER_BASH_SHELL) return process.env.PI_USER_BASH_SHELL; if (process.env.SHELL && ["pwsh", "pwsh.exe"].includes(basename(process.env.SHELL).toLowerCase())) { return process.env.SHELL; } return "pwsh"; } function getPiProfileCommand() { const profilePath = process.env.PI_USER_BASH_PWSH_PROFILE ?? "$HOME/.config/powershell/pi-profile.ps1"; return `$piProfile = ${JSON.stringify(profilePath)}; if (Test-Path -LiteralPath $piProfile) { . $piProfile }`; } function createPwshOperations(): BashOperations { return { exec(command, cwd, options) { return new Promise((resolve, reject) => { if (!existsSync(cwd)) { reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute PowerShell commands.`)); return; } // Run PowerShell 7 directly instead of wrapping through pi's local bash // backend. On Windows, createLocalBashOperations() still needs Git Bash, // which defeats the purpose of using pwsh for user `!` commands. // Keep -NoProfile, then source a dedicated non-interactive Pi profile. const wrappedCommand = `${getPiProfileCommand()}; & { ${command} }`; const child = spawn( getPwshPath(), [ "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", wrappedCommand, ], { cwd, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...options.env, // Keep PowerShell startup quiet/non-interactive and avoid profile or // terminal prompt integration warnings. POWERSHELL_TELEMETRY_OPTOUT: "1", NO_COLOR: process.env.NO_COLOR ?? "1", TERM: process.env.TERM ?? "dumb", }, }, ); let settled = false; let timedOut = false; let timeout: NodeJS.Timeout | undefined; let forcedKillTimeout: NodeJS.Timeout | undefined; const terminateProcessTree = (force = false) => { if (child.exitCode !== null) return; if (process.platform === "win32" && child.pid) { const args = ["/pid", String(child.pid), "/t"]; if (force) args.push("/f"); try { const killer = spawn("taskkill", args, { stdio: "ignore", windowsHide: true }); killer.on("error", () => { try { child.kill(force ? "SIGKILL" : "SIGTERM"); } catch {} }); return; } catch {} } try { child.kill(force ? "SIGKILL" : "SIGTERM"); } catch {} }; const abort = () => { if (forcedKillTimeout || child.exitCode !== null) return; terminateProcessTree(); forcedKillTimeout = setTimeout(() => terminateProcessTree(true), 3000); forcedKillTimeout.unref?.(); }; const cleanup = () => { if (timeout) clearTimeout(timeout); if (forcedKillTimeout) clearTimeout(forcedKillTimeout); options.signal?.removeEventListener("abort", abort); }; child.stdout?.on("data", options.onData); child.stderr?.on("data", options.onData); child.on("error", (error) => { if (settled) return; settled = true; cleanup(); reject(error); }); child.on("close", (exitCode) => { if (settled) return; settled = true; cleanup(); if (options.signal?.aborted) reject(new Error("aborted")); else if (timedOut) reject(new Error(`timeout:${options.timeout}`)); else resolve({ exitCode }); }); if (options.signal?.aborted) abort(); else options.signal?.addEventListener("abort", abort, { once: true }); if (options.timeout && options.timeout > 0) { timeout = setTimeout(() => { timedOut = true; abort(); }, options.timeout * 1000); } }); }, }; } export default function (pi: ExtensionAPI) { const operations = createPwshOperations(); pi.on("user_bash", () => ({ operations })); }