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
6 changes: 6 additions & 0 deletions .changeset/prefer-powershell-7-on-windows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---

Prefer PowerShell 7 over legacy Windows PowerShell 5.1 when running agent commands on Windows. PowerShell 7 installs are now found even when `pwsh` is missing from PATH, Agent Manager setup and run scripts launch pwsh when available, and an explicit `shell` in kilo.json still overrides detection.
20 changes: 19 additions & 1 deletion packages/core/src/kilocode/powershell.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,25 @@
import { statSync } from "fs"
import path from "path"
import { which } from "../util/which"

export function args(command: string) {
return ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script(command)]
}

export const locations = (env: NodeJS.ProcessEnv = process.env) =>
[
env["ProgramFiles"] && path.join(env["ProgramFiles"], "PowerShell", "7"),
env["ProgramFiles(x86)"] && path.join(env["ProgramFiles(x86)"], "PowerShell", "7"),
env["LOCALAPPDATA"] && path.join(env["LOCALAPPDATA"], "Microsoft", "WindowsApps"),
]
.filter((item): item is string => Boolean(item))
.map((root) => path.join(root, "pwsh.exe"))

export const probe = (env: NodeJS.ProcessEnv = process.env) =>
locations(env).filter((file) => statSync(file, { throwIfNoEntry: false })?.isFile())

export const pwsh = (env: NodeJS.ProcessEnv = process.env) => which("pwsh", env) ?? probe(env)[0]

@WebReflection WebReflection Aug 24, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not a big deal, neither a blocker, rather a FYI, nowadays it'd be probe(env).at(0) to avoid "messing" with arrays (because if the length is 0 it would access an undefined index)


const setup = `[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false);
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false);
$OutputEncoding = [Console]::OutputEncoding;
Expand Down Expand Up @@ -123,4 +141,4 @@ function block(command: string, start: number, open: string, close: string) {
}
}

export const PowerShell = { args }
export const PowerShell = { args, locations, probe, pwsh }
3 changes: 2 additions & 1 deletion packages/core/src/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ function resolve(file: string) {
function win() {
return Array.from(
new Set(
[which("pwsh"), which("powershell"), gitbash(), process.env.COMSPEC || "cmd.exe"]
// kilocode_change - probe known PowerShell 7 install locations so legacy 5.1 is not picked when pwsh is off PATH
[PowerShell.pwsh(), which("powershell"), gitbash(), process.env.COMSPEC || "cmd.exe"] // kilocode_change
.filter((item): item is string => Boolean(item))
.map(full),
),
Expand Down
120 changes: 120 additions & 0 deletions packages/core/test/kilocode/powershell.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, expect, test } from "bun:test"
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"
import { tmpdir } from "os"
import path from "path"
import { Shell } from "@opencode-ai/core/shell"
import { PowerShell } from "@opencode-ai/core/kilocode/powershell"
import { which } from "@opencode-ai/core/util/which"

const LEGACY = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"

const knownLocations = () => {
const roots = [
process.env.ProgramFiles && path.join(process.env.ProgramFiles, "PowerShell", "7"),
process.env["ProgramFiles(x86)"] && path.join(process.env["ProgramFiles(x86)"], "PowerShell", "7"),
process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, "Microsoft", "WindowsApps"),
].filter((item): item is string => Boolean(item))
return roots.map((root) => path.join(root, "pwsh.exe")).filter((file) => existsSync(file))
}

const pwshInstalled = () => Boolean(which("pwsh")) || knownLocations().length > 0

// Remove every PATH directory that can resolve pwsh or powershell so detection
// cannot fall back to PATH lookup and must find installs on its own.
const withoutPowershellDirs = () =>
(process.env.PATH ?? "")
.split(path.delimiter)
.filter(Boolean)
.filter((dir) => !/powershell/i.test(dir) && !existsSync(path.join(dir, "pwsh.exe")))
.join(path.delimiter)

function withEnv(env: { PATH?: string; SHELL?: string }, fn: () => void) {
const prevPath = process.env.PATH
const prevShell = process.env.SHELL
if (env.PATH === undefined) delete process.env.PATH
else process.env.PATH = env.PATH
if (env.SHELL === undefined) delete process.env.SHELL
else process.env.SHELL = env.SHELL
Shell.preferred.reset()
Shell.acceptable.reset()
try {
fn()
} finally {
if (prevPath === undefined) delete process.env.PATH
else process.env.PATH = prevPath
if (prevShell === undefined) delete process.env.SHELL
else process.env.SHELL = prevShell
Shell.preferred.reset()
Shell.acceptable.reset()
}
}

if (process.platform === "win32") {
describe("windows powershell selection", () => {
test("prefers an installed powershell 7 when pwsh is absent from PATH", () => {
if (!pwshInstalled()) return
withEnv({ PATH: withoutPowershellDirs(), SHELL: undefined }, () => {
expect(Shell.name(Shell.preferred())).toBe("pwsh")
expect(Shell.name(Shell.acceptable())).toBe("pwsh")
})
})

test("prefers pwsh over legacy 5.1 on the unmodified PATH", () => {
if (!pwshInstalled()) return
withEnv({ SHELL: undefined }, () => {
expect(Shell.name(Shell.preferred())).toBe("pwsh")
})
})

test("explicit shell config still overrides detection", () => {
if (!existsSync(LEGACY)) return
expect(Shell.preferred(LEGACY)).toBe(LEGACY)
expect(Shell.acceptable(LEGACY)).toBe(LEGACY)
})
})
}

describe("powershell install probing", () => {
test("lists known locations in priority order", () => {
expect(
PowerShell.locations({
ProgramFiles: "C:\\Program Files",
"ProgramFiles(x86)": "C:\\Program Files (x86)",
LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local",
}),
).toEqual([
path.join("C:\\Program Files", "PowerShell", "7", "pwsh.exe"),
path.join("C:\\Program Files (x86)", "PowerShell", "7", "pwsh.exe"),
path.join("C:\\Users\\u\\AppData\\Local", "Microsoft", "WindowsApps", "pwsh.exe"),
])
})

test("skips unset environment roots", () => {
expect(PowerShell.locations({})).toEqual([])
})

test("probe and pwsh resolve an installed pwsh outside PATH", () => {
const root = mkdtempSync(path.join(tmpdir(), "pwsh-probe-"))
try {
const dir = path.join(root, "PowerShell", "7")
mkdirSync(dir, { recursive: true })
const file = path.join(dir, "pwsh.exe")
writeFileSync(file, "")
expect(PowerShell.probe({ ProgramFiles: root })).toEqual([file])
expect(PowerShell.pwsh({ PATH: "", ProgramFiles: root })).toBe(file)
} finally {
rmSync(root, { recursive: true, force: true })
}
})

test("probe ignores location roots without pwsh", () => {
const root = mkdtempSync(path.join(tmpdir(), "pwsh-probe-empty-"))
try {
mkdirSync(path.join(root, "PowerShell", "7"), { recursive: true })
expect(PowerShell.probe({ ProgramFiles: root })).toEqual([])
expect(PowerShell.pwsh({ PATH: "", ProgramFiles: root })).toBeUndefined()
} finally {
rmSync(root, { recursive: true, force: true })
}
})
})
3 changes: 2 additions & 1 deletion packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* actual execution to an injected RunTask callback (provided by the caller).
*/

import { powershellCommand } from "../util/powershell"
import { SetupScriptService, type SetupScriptInfo } from "./SetupScriptService"

interface SetupScriptEnvironment {
Expand All @@ -31,7 +32,7 @@ function quoteCmdArg(value: string): string {
export function buildSetupTaskCommand(script: SetupScriptInfo): { command: string; args: string[] } {
if (script.kind === "powershell") {
return {
command: "powershell.exe",
command: powershellCommand(),
args: ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script.path],
}
}
Expand Down
3 changes: 2 additions & 1 deletion packages/kilo-vscode/src/agent-manager/run/service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as fs from "node:fs"
import * as path from "node:path"
import { KILO_DIR } from "../constants"
import { powershellCommand } from "../../util/powershell"

const RUN_SCRIPT_FILENAME = "run-script"
const RUN_SCRIPT_SHELL_FILENAME = "run-script.sh"
Expand Down Expand Up @@ -85,7 +86,7 @@ function validated(file: string, dir: string): boolean {
export function buildRunTaskCommand(script: RunScriptInfo): { command: string; args: string[] } {
if (script.kind === "powershell") {
return {
command: "powershell.exe",
command: powershellCommand(),
args: ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script.path],
}
}
Expand Down
33 changes: 33 additions & 0 deletions packages/kilo-vscode/src/util/powershell.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { statSync } from "node:fs"
import * as path from "node:path"

/**
* Well-known PowerShell 7 install locations on Windows. The Store install only
* exposes `pwsh.exe` through the WindowsApps execution alias, which is often
* missing from the PATH of spawned child processes.
*/
export function locations(env: NodeJS.ProcessEnv = process.env): string[] {
const roots = [
env["ProgramFiles"] && path.join(env["ProgramFiles"], "PowerShell", "7"),
env["ProgramFiles(x86)"] && path.join(env["ProgramFiles(x86)"], "PowerShell", "7"),
env["LOCALAPPDATA"] && path.join(env["LOCALAPPDATA"], "Microsoft", "WindowsApps"),
].filter((item): item is string => Boolean(item))
return roots.map((root) => path.join(root, "pwsh.exe"))
}

function exists(file: string): boolean {
return statSync(file, { throwIfNoEntry: false })?.isFile() === true
}

export function pwshPath(env: NodeJS.ProcessEnv = process.env): string | undefined {
const dirs = [...(env.PATH ?? env.Path ?? "").split(path.delimiter), ...locations(env)]
return dirs
.filter(Boolean)
.map((dir) => path.join(dir, "pwsh.exe"))
.find(exists)
}

/** Prefer PowerShell 7; legacy 5.1 writes UTF-16LE BOM output on redirection. */
export function powershellCommand(env: NodeJS.ProcessEnv = process.env): string {
return pwshPath(env) ?? "powershell.exe"
}
38 changes: 38 additions & 0 deletions packages/kilo-vscode/tests/unit/powershell.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, it, expect } from "bun:test"
import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import { locations, powershellCommand, pwshPath } from "../../src/util/powershell"

describe("powershellCommand", () => {
it("lists known Windows install locations in priority order", () => {
expect(
locations({
ProgramFiles: "C:\\Program Files",
"ProgramFiles(x86)": "C:\\Program Files (x86)",
LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local",
}),
).toEqual([
path.join("C:\\Program Files", "PowerShell", "7", "pwsh.exe"),
path.join("C:\\Program Files (x86)", "PowerShell", "7", "pwsh.exe"),
path.join("C:\\Users\\u\\AppData\\Local", "Microsoft", "WindowsApps", "pwsh.exe"),
])
expect(locations({})).toEqual([])
})

it("falls back to legacy powershell.exe when nothing is found", () => {
expect(powershellCommand({ PATH: "" })).toBe("powershell.exe")
})

it("prefers a pwsh.exe found on the injected PATH", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pwsh-path-"))
try {
const file = path.join(root, "pwsh.exe")
fs.writeFileSync(file, "")
expect(pwshPath({ PATH: root })).toBe(file)
expect(powershellCommand({ PATH: root })).toBe(file)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
})
3 changes: 2 additions & 1 deletion packages/kilo-vscode/tests/unit/run-script-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import { buildRunTaskCommand, RunScriptService } from "../../src/agent-manager/run/service"
import { powershellCommand } from "../../src/util/powershell"

function tmpdir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "run-script-service-test-"))
Expand Down Expand Up @@ -59,7 +60,7 @@ describe("RunScriptService", () => {
args: ["/tmp/run-script"],
})
expect(buildRunTaskCommand({ path: "C:\\repo\\.kilo\\run-script.ps1", kind: "powershell" })).toEqual({
command: "powershell.exe",
command: powershellCommand(),
args: ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "C:\\repo\\.kilo\\run-script.ps1"],
})
expect(buildRunTaskCommand({ path: "C:\\repo path\\.kilo\\run-script.cmd", kind: "cmd" })).toEqual({
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/kilocode/background-process/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Instance, type InstanceContext } from "@/kilocode/instance"
import { KiloShutdown } from "@/kilocode/cli/shutdown"
import { model as modelEnv } from "@/kilocode/process/env"
import { SessionID } from "@/session/schema"
import { PowerShell } from "@/kilocode/shell/shell"
import { Shell } from "@opencode-ai/core/shell"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Process } from "@/util/process"
Expand All @@ -32,6 +33,7 @@ import * as Ports from "./ports"

export namespace BackgroundProcess {
const log = Log.create({ service: "background-process" })
const pwsh = PowerShell.pwsh() ?? "powershell.exe"
const MAX = 200 * 1024
const KILL_MS = 3_000
const READY_MS = 30_000
Expand Down Expand Up @@ -669,7 +671,7 @@ export namespace BackgroundProcess {
const token = active.token
if (!pid || !token) return "unknown"
const query = `$p=Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($p) { [Console]::Out.Write($p.CommandLine) }`
const out = await Process.text(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", query], {
const out = await Process.text([pwsh, "-NoProfile", "-NonInteractive", "-Command", query], {
nothrow: true,
abort: AbortSignal.timeout(2_000),
timeout: 2_000,
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/kilocode/background-process/runner.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { KiloPtySelfCommand } from "@/kilocode/pty/self-command"
import { PowerShell } from "@/kilocode/shell/shell"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { isRecord } from "@/util/record"
Expand All @@ -11,6 +12,7 @@ export namespace BackgroundProcessRunner {
const MODE = 0o600
const MAX = 1024 * 1024
const KEEP = 200 * 1024
const pwsh = PowerShell.pwsh() ?? "powershell.exe"

export type Input = {
token: string
Expand Down Expand Up @@ -96,7 +98,7 @@ export namespace BackgroundProcessRunner {
async function descendants(root: number, seen: Map<number, string>, active: boolean) {
const query =
"Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CreationDate | ConvertTo-Json -Compress"
const out = await Process.text(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", query], {
const out = await Process.text([pwsh, "-NoProfile", "-NonInteractive", "-Command", query], {
nothrow: true,
abort: AbortSignal.timeout(2_000),
timeout: 2_000,
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/kilocode/shell/shell.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { args, PowerShell } from "@opencode-ai/core/kilocode/powershell"
export { args, PowerShell, pwsh } from "@opencode-ai/core/kilocode/powershell"
Loading