diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..7a1de18
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,15 @@
+# Normalize text files to LF in the repo AND in the working tree (eol=lf beats core.autocrlf),
+# so a Windows checkout matches what Prettier's endOfLine=lf expects and shell scripts stay
+# runnable under Git-Bash.
+* text=auto eol=lf
+
+# Shell scripts must be LF (a CRLF shebang line breaks the interpreter).
+*.sh text eol=lf
+
+# PowerShell is the one Windows-native text format we ship; keep it CRLF for notepad/PS 5.1
+# friendliness (PowerShell itself accepts both).
+*.ps1 text eol=crlf
+
+# Binary assets: never subject to text normalization or diffs.
+*.png binary
+*.webp binary
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bf90052..3d7e33b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,6 +1,7 @@
# CI: build -> style (Prettier) -> typecheck (tsc) -> unit tests (vitest) -> live e2e (DeepSeek).
# Build first: core's exports point at dist/, and cli's type resolution and runtime imports both need core's build output.
# e2e needs the repo secret DEEPSEEK_API_KEY; when absent (e.g. forks) that step self-skips and the other checks run as usual.
+# A second job repeats build/typecheck/test on windows-latest (see ci-windows below).
name: CI
# Limit triggers to avoid duplicate runs: push runs only on main/dev; PRs always run once (no target-branch filter --
@@ -55,3 +56,55 @@ jobs:
exit 0
fi
pnpm test:e2e
+
+ # Windows: the same build/typecheck/test gates on windows-latest (the ubuntu job above stays the
+ # required one). The runner ships Git-Bash on PATH, so core's exec_command tests run through the
+ # shell resolver's bash pick; genuinely POSIX-only tests skip themselves via process.platform
+ # guards (not CI filters), so local Windows devs see the same behavior. Line endings come from
+ # .gitattributes (LF working tree), keeping build outputs and fixtures byte-identical.
+ # No format:check (same files as ubuntu) and no live-LLM e2e here (ubuntu-only budget).
+ ci-windows:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v5
+
+ # pnpm version comes from package.json's packageManager field.
+ - uses: pnpm/action-setup@v4
+
+ - uses: actions/setup-node@v5
+ with:
+ node-version: 24
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Build (tsup)
+ run: pnpm build
+
+ - name: Typecheck (tsc)
+ run: pnpm typecheck
+
+ - name: Unit tests (vitest)
+ run: pnpm test
+
+ # The Linux job cannot validate PowerShell syntax; parse (never execute) the installer
+ # scripts with the real PowerShell parser so a broken install.ps1 cannot ship.
+ - name: Parse installer scripts (PowerShell)
+ shell: pwsh
+ run: |
+ $failed = $false
+ foreach ($f in @("install.ps1", "packages/landing/public/install.ps1")) {
+ $tokens = $null
+ $errors = $null
+ # .Path: hand ParseFile a plain string, not a PathInfo object.
+ [System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $f).Path, [ref]$tokens, [ref]$errors) | Out-Null
+ if ($errors.Count -gt 0) {
+ $failed = $true
+ Write-Host "${f}: $($errors.Count) parse error(s)"
+ $errors | ForEach-Object { Write-Host " $($_.Extent.StartLineNumber): $($_.Message)" }
+ } else {
+ Write-Host "${f}: OK"
+ }
+ }
+ if ($failed) { exit 1 }
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a418bd2..7af6e01 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -4,9 +4,9 @@
# already-released tag skips the build/upload entirely and only re-runs npm publishing.
# Two parallel jobs (the release job is gated on the existence check):
# - release: build the monorepo -> pnpm deploy a production CLI dir -> assemble penguin/ (bin + lib + web)
-# -> four platform packages each bundling the official Node runtime + a universal package -> SHA256 files -> upload to the Release.
-# Artifacts: penguin-{linux,darwin}-{x64,arm64}.tar.gz, penguin-universal.tar.gz,
-# their .sha256 files, SHA256SUMS, and install.sh; one version per tag, multiple versions coexist.
+# -> five platform packages each bundling the official Node runtime + a universal package -> SHA256 files -> upload to the Release.
+# Artifacts: penguin-{linux,darwin}-{x64,arm64}.tar.gz, penguin-win32-x64.zip, penguin-universal.tar.gz,
+# their .sha256 files, SHA256SUMS, install.sh, and install.ps1; one version per tag, multiple versions coexist.
# - publish-npm: publish the whole chain (@prismshadow/penguin-skills -> @prismshadow/penguin-core
# -> @prismshadow/penguin-server -> @prismshadow/penguin-cli) to npm at the tag version.
# skills/core serve the penguin-sdk Skill's `npm install`; server ships the built web assets inside
@@ -150,12 +150,53 @@ jobs:
rm -rf out/penguin/node
tar -czf dist-artifacts/penguin-universal.tar.gz -C out penguin
- # SHA256SUMS summary + a same-named .sha256 per artifact (install.sh verifies against the latter).
+ # Windows package: same lib/ + web/ layout, but a .zip (the native format), the official
+ # win-x64 Node runtime — whose zip has node.exe at the ARCHIVE ROOT, not bin/ — and
+ # cmd/ps1 launchers replacing the sh one (resolve their own dir, default PENGUIN_WEB_DIST
+ # to the sibling web\, prefer the bundled node\node.exe, fall back to system node).
+ # install.ps1 verifies and unpacks this zip; in PowerShell the .ps1 shim wins over .cmd,
+ # in cmd.exe only the .cmd is found — both forward all args and the exit code.
+ - name: Package win-x64 zip
+ run: |
+ name="node-$NODE_RUNTIME_VERSION-win-x64"
+ curl -fsSL "https://nodejs.org/dist/$NODE_RUNTIME_VERSION/$name.zip" -o "/tmp/$name.zip"
+ rm -rf /tmp/node-runtime out/penguin/node
+ mkdir -p /tmp/node-runtime
+ unzip -q "/tmp/$name.zip" -d /tmp/node-runtime
+ mv "/tmp/node-runtime/$name" out/penguin/node
+ rm -f out/penguin/bin/penguin
+ cat > out/penguin/bin/penguin.cmd <<'EOF'
+ @echo off
+ setlocal
+ set "DIR=%~dp0.."
+ if not defined PENGUIN_WEB_DIST set "PENGUIN_WEB_DIST=%DIR%\web"
+ if exist "%DIR%\node\node.exe" (
+ "%DIR%\node\node.exe" "%DIR%\lib\dist\index.js" %*
+ ) else (
+ node "%DIR%\lib\dist\index.js" %*
+ )
+ exit /b %ERRORLEVEL%
+ EOF
+ # cmd.exe is only fully reliable with CRLF batch files.
+ sed -i 's/$/\r/' out/penguin/bin/penguin.cmd
+ cat > out/penguin/bin/penguin.ps1 <<'EOF'
+ $dir = Split-Path -Parent $PSScriptRoot
+ if (-not $env:PENGUIN_WEB_DIST) { $env:PENGUIN_WEB_DIST = Join-Path $dir "web" }
+ $node = Join-Path $dir "node\node.exe"
+ if (-not (Test-Path $node)) { $node = "node" }
+ & $node (Join-Path $dir "lib\dist\index.js") @args
+ exit $LASTEXITCODE
+ EOF
+ # PowerShell accepts LF, but ship CRLF like the .cmd (and the repo's *.ps1 eol=crlf attribute).
+ sed -i 's/$/\r/' out/penguin/bin/penguin.ps1
+ (cd out && zip -qr ../dist-artifacts/penguin-win32-x64.zip penguin)
+
+ # SHA256SUMS summary + a same-named .sha256 per artifact (install.sh / install.ps1 verify against the latter).
- name: Generate SHA256 checksums
run: |
cd dist-artifacts
- sha256sum *.tar.gz > SHA256SUMS
- for f in *.tar.gz; do
+ sha256sum *.tar.gz *.zip > SHA256SUMS
+ for f in *.tar.gz *.zip; do
sha256sum "$f" > "$f.sha256"
done
@@ -192,9 +233,11 @@ jobs:
generate_release_notes: ${{ steps.notes.outputs.generate }}
files: |
dist-artifacts/*.tar.gz
+ dist-artifacts/*.zip
dist-artifacts/*.sha256
dist-artifacts/SHA256SUMS
install.sh
+ install.ps1
publish-npm:
name: Publish npm packages
diff --git a/.prettierrc.json b/.prettierrc.json
index de753c5..b65a2da 100644
--- a/.prettierrc.json
+++ b/.prettierrc.json
@@ -1,3 +1,4 @@
{
- "printWidth": 100
+ "printWidth": 100,
+ "endOfLine": "lf"
}
diff --git a/README.md b/README.md
index ead1609..93a4fd9 100644
--- a/README.md
+++ b/README.md
@@ -101,7 +101,7 @@ Each family's latest generation only — the app's **Models** page lists every b
| Requirement | Supported |
| ------------ | -------------------------------------------------------------------------- |
-| OS | Linux, macOS |
+| OS | Linux, macOS, Windows 10+ |
| Architecture | x64, arm64 |
| Runtime | bundled by the one-line installer (npm installs need Node >= 24) |
| Model | an API key for at least one model |
@@ -117,6 +117,8 @@ curl -fsSL https://penguin.ooo/install.sh | sh
penguin web # start the service and open http://127.0.0.1:7364 (first login: admin / penguin-2026)
```
+🪟 On Windows (PowerShell): `irm https://penguin.ooo/install.ps1 | iex`
+
📦 Or via npm: `npm install -g @prismshadow/penguin-cli`. Configure models on the in-app Models page, then chat.
### 🤖 CLI & SDK — for agents
@@ -149,7 +151,7 @@ for await (const output of session.run([userText("Create hello.txt containing hi
- [ ] Public release of the benchmark suite
- [ ] Desktop app
-- [ ] Windows support
+- [x] Windows support
- [ ] Agent company and templates
- [ ] Company-level self evolving
- [ ] OpenShell integration (permission-governed shell)
diff --git a/README.zh.md b/README.zh.md
index 31e9172..ca4900d 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -101,7 +101,7 @@ https://github.com/user-attachments/assets/aec49ae9-b743-467b-b247-37bedfeaa36e
| 需求项 | 支持情况 |
| -------- | ------------------------------------------------- |
-| 操作系统 | Linux、macOS |
+| 操作系统 | Linux、macOS、Windows 10+ |
| 架构 | x64、arm64 |
| 运行时 | 一行安装器自带(经 npm 安装需 Node >= 24) |
| 模型 | 至少一个模型的 API key |
@@ -117,6 +117,8 @@ curl -fsSL https://penguin.ooo/install.sh | sh
penguin web # 启动服务并打开 http://127.0.0.1:7364(首次登录:admin / penguin-2026)
```
+🪟 Windows(PowerShell):`irm https://penguin.ooo/install.ps1 | iex`
+
📦 或经 npm 安装:`npm install -g @prismshadow/penguin-cli`。在应用内模型页配置模型后即可对话。
### 🤖 CLI 与 SDK——面向 Agent
@@ -149,7 +151,7 @@ for await (const output of session.run([userText("Create hello.txt containing hi
- [ ] Benchmark 套件正式发布
- [ ] 桌面端应用
-- [ ] Windows 系统支持
+- [x] Windows 系统支持
- [ ] Agent 公司与模板
- [ ] 公司级自进化能力
- [ ] 集成 OpenShell(带权限管控的 shell)
diff --git a/install.ps1 b/install.ps1
new file mode 100644
index 0000000..dfaa1ba
--- /dev/null
+++ b/install.ps1
@@ -0,0 +1,220 @@
+# PenguinHarness one-line installer for Windows.
+#
+# irm https://penguin.ooo/install.ps1 | iex
+#
+# Options:
+# $env:PENGUIN_VERSION = "vX.Y.Z" pin a version (same as -Version vX.Y.Z); default is the latest Release
+# $env:PENGUIN_INSTALL_DIR = "
" install dir; default $env:USERPROFILE\.penguin
+#
+# There is no -Universal on Windows: where the zip is unsuitable, install Node.js >= 24 and run
+# `npm install -g @prismshadow/penguin-cli` instead.
+#
+# The data dir (%USERPROFILE%\.penguin\data) sits under the install home but is never touched by
+# reinstall/upgrade (which only replace bin/lib/web/node). Upgrading = re-running this installer.
+#
+# Docs: https://penguin.ooo/docs/installation
+param(
+ [string]$Version = "",
+ [string]$InstallDir = ""
+)
+
+$ErrorActionPreference = "Stop"
+$ProgressPreference = "SilentlyContinue" # Invoke-WebRequest progress rendering slows downloads massively on PS 5.1
+
+$Repo = "https://github.com/Prism-Shadow/penguin-harness"
+$Asset = "penguin-win32-x64.zip"
+
+function Fail([string]$Message) {
+ # `throw` rather than `exit`: the penguin.ooo forwarder runs this installer as an in-memory
+ # script block (see packages/landing/public/install.ps1), where `exit` would terminate the
+ # user's whole PowerShell session. `throw` aborts cleanly in both file and script-block runs.
+ throw "error: $Message"
+}
+
+# --- Resolve options (parameters win over env vars, mirroring install.sh's --version) ---
+if (-not $Version) { $Version = if ($env:PENGUIN_VERSION) { $env:PENGUIN_VERSION } else { "" } }
+if (-not $InstallDir) {
+ $InstallDir = if ($env:PENGUIN_INSTALL_DIR) { $env:PENGUIN_INSTALL_DIR } else { Join-Path $env:USERPROFILE ".penguin" }
+}
+
+# --- Platform preconditions: 64-bit Windows; the only Windows package is x64 (ARM64 runs it emulated) ---
+if (-not [Environment]::Is64BitOperatingSystem) {
+ Fail "32-bit Windows is not supported. Install Node.js >= 24 and use: npm install -g @prismshadow/penguin-cli"
+}
+if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") {
+ Write-Host "note: no native ARM64 package yet; installing the x64 package (runs via emulation)."
+}
+
+# PowerShell 5.1 defaults to TLS 1.0 on older systems; GitHub requires TLS 1.2+.
+try {
+ [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
+} catch {
+ # .NET builds where the enum is immutable already default to TLS 1.2+.
+}
+
+# --- Download (latest Release by default; PENGUIN_VERSION pins a version) ---
+if ($Version) {
+ $BaseUrl = "$Repo/releases/download/$Version"
+} else {
+ $BaseUrl = "$Repo/releases/latest/download"
+}
+$Tmp = Join-Path ([IO.Path]::GetTempPath()) "penguin-install-$PID"
+if (Test-Path $Tmp) { Remove-Item -Recurse -Force $Tmp }
+New-Item -ItemType Directory -Path $Tmp | Out-Null
+# Pre-declare so the finally block can read them even when an early failure skipped the
+# assignments (the user's session may run this under Set-StrictMode via the forwarder).
+$Staging = $null
+$OldDir = $null
+
+try {
+ Write-Host "Downloading $BaseUrl/$Asset ..."
+ $ZipPath = Join-Path $Tmp $Asset
+ try {
+ Invoke-WebRequest -Uri "$BaseUrl/$Asset" -OutFile $ZipPath -UseBasicParsing
+ } catch {
+ Fail "download failed. Check the version tag and your network, then retry. ($($_.Exception.Message))"
+ }
+
+ # --- SHA256 verify: only when the .sha256 asset exists (skip on 404) ---
+ $ShaPath = Join-Path $Tmp "$Asset.sha256"
+ $HaveSha = $true
+ try {
+ Invoke-WebRequest -Uri "$BaseUrl/$Asset.sha256" -OutFile $ShaPath -UseBasicParsing
+ } catch {
+ $HaveSha = $false
+ Write-Host "warning: checksum file not available; skipping verification."
+ }
+ if ($HaveSha) {
+ # The .sha256 file is ` ` (sha256sum format); the first token is the hash.
+ $Expected = ((Get-Content $ShaPath -Raw).Trim() -split "\s+")[0]
+ $Actual = (Get-FileHash -Algorithm SHA256 $ZipPath).Hash
+ if ($Expected -and ($Actual -ieq $Expected)) {
+ Write-Host "Checksum OK."
+ } else {
+ Fail "checksum mismatch for $Asset."
+ }
+ }
+
+ # --- Extract and swap into place: expand into a staging dir under the install dir (same volume,
+ # so the swap below is cheap renames), then rename-then-delete: move the old dirs aside first
+ # (a locked file fails fast here, before anything is deleted), move the new ones in, then
+ # drop the old. The data dir (%USERPROFILE%\.penguin\data) is untouched. ---
+ New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
+ $Staging = Join-Path $InstallDir ".staging.$PID"
+ $OldDir = Join-Path $InstallDir ".old.$PID"
+ if (Test-Path $Staging) { Remove-Item -Recurse -Force $Staging }
+ if (Test-Path $OldDir) { Remove-Item -Recurse -Force $OldDir }
+ New-Item -ItemType Directory -Path $Staging | Out-Null
+
+ Write-Host "Extracting ..."
+ Expand-Archive -Path $ZipPath -DestinationPath $Staging -Force
+ $NewRoot = Join-Path $Staging "penguin"
+ if (-not (Test-Path $NewRoot)) { Fail "unexpected archive layout: top-level penguin\ missing." }
+ if (-not (Test-Path (Join-Path $NewRoot "bin"))) { Fail "unexpected archive layout: penguin\bin missing." }
+
+ $Dirs = @("bin", "lib", "web", "node")
+ $Moved = @()
+ New-Item -ItemType Directory -Path $OldDir | Out-Null
+ try {
+ foreach ($d in $Dirs) {
+ $Existing = Join-Path $InstallDir $d
+ if (Test-Path $Existing) {
+ Move-Item -Path $Existing -Destination (Join-Path $OldDir $d)
+ $Moved += $d
+ }
+ }
+ } catch {
+ # Roll the already-moved dirs back so a locked install stays intact and usable.
+ foreach ($d in $Moved) {
+ Move-Item -Path (Join-Path $OldDir $d) -Destination (Join-Path $InstallDir $d) -ErrorAction SilentlyContinue
+ }
+ Fail "files in $InstallDir are locked: close running penguin processes (and any node.exe they started), then retry. ($($_.Exception.Message))"
+ }
+ foreach ($d in $Dirs) {
+ $Src = Join-Path $NewRoot $d
+ if (Test-Path $Src) {
+ Move-Item -Path $Src -Destination (Join-Path $InstallDir $d)
+ }
+ }
+ Remove-Item -Recurse -Force $OldDir -ErrorAction SilentlyContinue
+ if (Test-Path $OldDir) {
+ Write-Host "warning: could not fully remove $OldDir (files in use); delete it after closing running penguin processes."
+ }
+} finally {
+ Remove-Item -Recurse -Force $Tmp -ErrorAction SilentlyContinue
+ if ($Staging -and (Test-Path $Staging)) { Remove-Item -Recurse -Force $Staging -ErrorAction SilentlyContinue }
+}
+
+# --- Launcher shims: shipped in the zip; (re)generate only when missing ---
+$CmdShim = Join-Path $InstallDir "bin\penguin.cmd"
+if (-not (Test-Path $CmdShim)) {
+ @(
+ '@echo off'
+ 'setlocal'
+ 'set "DIR=%~dp0.."'
+ 'if not defined PENGUIN_WEB_DIST set "PENGUIN_WEB_DIST=%DIR%\web"'
+ 'if exist "%DIR%\node\node.exe" ('
+ ' "%DIR%\node\node.exe" "%DIR%\lib\dist\index.js" %*'
+ ') else ('
+ ' node "%DIR%\lib\dist\index.js" %*'
+ ')'
+ 'exit /b %ERRORLEVEL%'
+ ) | Set-Content -Path $CmdShim -Encoding ascii
+}
+$Ps1Shim = Join-Path $InstallDir "bin\penguin.ps1"
+if (-not (Test-Path $Ps1Shim)) {
+ @(
+ '$dir = Split-Path -Parent $PSScriptRoot'
+ 'if (-not $env:PENGUIN_WEB_DIST) { $env:PENGUIN_WEB_DIST = Join-Path $dir "web" }'
+ '$node = Join-Path $dir "node\node.exe"'
+ 'if (-not (Test-Path $node)) { $node = "node" }'
+ '& $node (Join-Path $dir "lib\dist\index.js") @args'
+ 'exit $LASTEXITCODE'
+ ) | Set-Content -Path $Ps1Shim -Encoding ascii
+}
+if (-not (Test-Path $CmdShim)) { Fail "install incomplete: $CmdShim missing." }
+
+# --- User PATH: append \bin once; new terminals pick it up.
+# Go through the registry, not [Environment]::*EnvironmentVariable: GetEnvironmentVariable
+# expands REG_EXPAND_SZ and SetEnvironmentVariable writes back REG_SZ, which would
+# irreversibly hard-code a user's %USERPROFILE%-style Path entries. Read the raw
+# (unexpanded) value, append to it, and write it back with its original value kind.
+# The registry only exists on Windows; skip the block elsewhere (functional test runs
+# of this script on pwsh/Linux — where the old API was a silent no-op anyway). ---
+$BinDir = Join-Path $InstallDir "bin"
+if ($env:OS -eq "Windows_NT") {
+ $EnvKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey("Environment", $true)
+ if ($null -eq $EnvKey) { $EnvKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey("Environment") }
+ try {
+ # Missing Path value: create it as REG_EXPAND_SZ (the kind Windows itself uses for Path).
+ $Kind = [Microsoft.Win32.RegistryValueKind]::ExpandString
+ try { $Kind = $EnvKey.GetValueKind("Path") } catch {}
+ $RawPath = [string]$EnvKey.GetValue("Path", "", [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
+ # Membership is checked per entry after expansion, so both literal and %VAR%-style
+ # spellings of the bin dir count as already present; the append itself stays raw.
+ $OnPath = @($RawPath -split ";" | Where-Object { $_ } | ForEach-Object {
+ [Environment]::ExpandEnvironmentVariables($_).TrimEnd("\")
+ }) -contains $BinDir.TrimEnd("\")
+ if (-not $OnPath) {
+ $NewPath = if ($RawPath -and -not $RawPath.EndsWith(";")) { "$RawPath;$BinDir" } else { "$RawPath$BinDir" }
+ $EnvKey.SetValue("Path", $NewPath, $Kind)
+ Write-Host ""
+ Write-Host "note: appended $BinDir to your user Path. Restart your terminal so 'penguin' is found."
+ }
+ } finally {
+ $EnvKey.Close()
+ }
+}
+# Make `penguin` work in this session too.
+if (($env:Path -split ";") -notcontains $BinDir) { $env:Path = "$env:Path;$BinDir" }
+
+# --- Finish: print version and getting-started tips ---
+$InstalledVersion = "unknown"
+try { $InstalledVersion = (& $CmdShim --version 2>$null | Select-Object -First 1) } catch {}
+Write-Host ""
+Write-Host "PenguinHarness $InstalledVersion installed to $InstallDir"
+Write-Host ""
+Write-Host "Get started:"
+Write-Host " penguin --help # all commands"
+Write-Host " penguin web # start the Web UI at http://127.0.0.1:7364 (initial login: admin / penguin-2026)"
+Write-Host " penguin server # headless server (PORT / HOST to override)"
diff --git a/package.json b/package.json
index da6093a..66ce0de 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,7 @@
"test:e2e": "pnpm --filter @prismshadow/penguin-core test:e2e",
"build": "pnpm -r build && pnpm link:cli",
"link:cli": "pnpm --dir packages/cli link --global || echo '[link:cli] pnpm global directory not configured; skipping the global penguin link (run pnpm setup once first)'",
- "penguin": "PENGUIN_HOME=\"${PENGUIN_HOME:-$HOME/.penguin/dev-data}\" tsx packages/cli/src/index.ts",
+ "penguin": "node scripts/run-with-env.mjs PENGUIN_HOME=~/.penguin/dev-data -- tsx packages/cli/src/index.ts",
"dev": "concurrently -n server,web -c cyan,magenta \"pnpm dev:server\" \"pnpm dev:web\"",
"dev:server": "pnpm --filter @prismshadow/penguin-server dev",
"dev:web": "node scripts/dev-prebuild.mjs && pnpm --filter @prismshadow/penguin-web dev",
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 68dbdd5..c479969 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -19,7 +19,7 @@
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "vitest run --passWithNoTests",
"build": "tsup",
- "penguin": "PENGUIN_HOME=\"${PENGUIN_HOME:-$HOME/.penguin/dev-data}\" tsx src/index.ts"
+ "penguin": "node ../../scripts/run-with-env.mjs PENGUIN_HOME=~/.penguin/dev-data -- tsx src/index.ts"
},
"dependencies": {
"@prismshadow/agenthub": "^0.4.1",
diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts
index b1fdc45..4dd42c5 100644
--- a/packages/cli/src/commands/config.ts
+++ b/packages/cli/src/commands/config.ts
@@ -325,6 +325,13 @@ export function registerConfigCommand(program: Command, t: Messages): void {
process.exitCode = 1;
return;
}
+ // Windows has no POSIX shell startup file to write and no /bin shell to restart into;
+ // refuse with a clear pointer instead of an ENOENT from spawning /bin/zsh.
+ if (process.platform === "win32") {
+ process.stderr.write(`${getMessages(lang).langWindowsUnsupported(lang)}\n`);
+ process.exitCode = 1;
+ return;
+ }
const { rcPath } = await applyLanguageToRc(lang, {
shell: process.env.SHELL,
home: homedir(),
diff --git a/packages/cli/src/i18n.ts b/packages/cli/src/i18n.ts
index 0fe7c4e..f548963 100644
--- a/packages/cli/src/i18n.ts
+++ b/packages/cli/src/i18n.ts
@@ -176,6 +176,8 @@ export interface Messages {
/** Example resume command shown when the REPL exits (dim print; only when this session has a resumable record). */
resumeHint(command: string): string;
langInvalid(value: string): string;
+ /** `config lang` persists via POSIX shell startup files; on Windows it refuses with a pointer to a user env var instead. */
+ langWindowsUnsupported(lang: string): string;
langSet(lang: string, rcPath: string): string;
langRestartConfirm(): string;
langRestart(): string;
@@ -383,6 +385,9 @@ const en: Messages = {
`[resumed] ${sessionId} · ${messageCount} message${messageCount === 1 ? "" : "s"} in the current context`,
resumeHint: (command) => `To continue this conversation: ${command}`,
langInvalid: (value) => `Invalid language "${value}". Use en or zh.`,
+ langWindowsUnsupported: (lang) =>
+ `penguin config lang persists via POSIX shell startup files, which Windows does not have.\n` +
+ `Set the user environment variable instead: setx PENGUIN_LANG ${lang} (new terminals pick it up).`,
langSet: (lang, rcPath) => `Language set to ${lang}; wrote PENGUIN_LANG to ${rcPath}.`,
langRestartConfirm: () => "Open a new shell now to apply? [y/N] ",
langRestart: () => "Opening a new shell with the new language (type exit to return)…",
@@ -550,6 +555,9 @@ const zh: Messages = {
`[已恢复] ${sessionId} · 当前上下文共 ${messageCount} 条消息`,
resumeHint: (command) => `继续本次对话:${command}`,
langInvalid: (value) => `无效的语言 "${value}"。请使用 en 或 zh。`,
+ langWindowsUnsupported: (lang) =>
+ `penguin config lang 通过 POSIX shell 启动文件持久化语言,Windows 上没有对应机制。\n` +
+ `请改为设置用户环境变量:setx PENGUIN_LANG ${lang}(新终端生效)。`,
langSet: (lang, rcPath) => `语言已设为 ${lang};已将 PENGUIN_LANG 写入 ${rcPath}。`,
langRestartConfirm: () => "现在打开新 shell 使其生效?[y/N] ",
langRestart: () => "正在打开使用新语言的新 shell(输入 exit 可返回)……",
diff --git a/packages/cli/test/config-model.test.ts b/packages/cli/test/config-model.test.ts
index 7fe50bc..010c2d2 100644
--- a/packages/cli/test/config-model.test.ts
+++ b/packages/cli/test/config-model.test.ts
@@ -97,7 +97,10 @@ describe("penguin config model add/list (--root plus provider / model_id stored
const file = projectConfigPath(tmpRoot, DEFAULT_PROJECT_ID);
expect(path.basename(file)).toBe(".project_config.toml");
- expect((await fs.stat(file)).mode & 0o777).toBe(0o600);
+ // POSIX-only: Windows has no owner-only mode bits (chmod maps to the read-only attribute).
+ if (process.platform !== "win32") {
+ expect((await fs.stat(file)).mode & 0o777).toBe(0o600);
+ }
const parsed = parseToml(await fs.readFile(file, "utf8")) as {
models: Array>;
};
diff --git a/packages/cli/test/config-vault.test.ts b/packages/cli/test/config-vault.test.ts
index eeaa908..9f35faf 100644
--- a/packages/cli/test/config-vault.test.ts
+++ b/packages/cli/test/config-vault.test.ts
@@ -64,7 +64,10 @@ describe("penguin config vault", () => {
const file = agentVaultPath(tmpRoot, DEFAULT_PROJECT_ID, "default_agent");
expect(path.basename(file)).toBe(".vault.toml");
- expect((await fs.stat(file)).mode & 0o777).toBe(0o600);
+ // POSIX-only: Windows has no owner-only mode bits (chmod maps to the read-only attribute).
+ if (process.platform !== "win32") {
+ expect((await fs.stat(file)).mode & 0o777).toBe(0o600);
+ }
expect(await fs.readFile(file, "utf8")).toContain("vault-secret-9876");
const list = await runVault(["list"]);
diff --git a/packages/cli/test/lang-config.test.ts b/packages/cli/test/lang-config.test.ts
index 71f2a4d..eba586a 100644
--- a/packages/cli/test/lang-config.test.ts
+++ b/packages/cli/test/lang-config.test.ts
@@ -5,26 +5,29 @@ import { afterEach, describe, expect, it } from "vitest";
import { applyLanguageToRc, resolveShellRc, upsertBlock } from "../src/lang-config.js";
describe("resolveShellRc", () => {
+ // rcPath is built with path.join, so the expectations join too (backslashes on Windows —
+ // where the `config lang` command refuses before ever calling this, but the pure function
+ // stays coherent).
it("maps zsh / bash / fish to their startup files and syntax", () => {
const zsh = resolveShellRc("/bin/zsh", "/home/u");
expect(zsh.kind).toBe("zsh");
- expect(zsh.rcPath).toBe("/home/u/.zshrc");
+ expect(zsh.rcPath).toBe(join("/home/u", ".zshrc"));
expect(zsh.body("zh")).toBe("export PENGUIN_LANG=zh");
const bash = resolveShellRc("/usr/bin/bash", "/home/u");
expect(bash.kind).toBe("bash");
- expect(bash.rcPath).toBe("/home/u/.bashrc");
+ expect(bash.rcPath).toBe(join("/home/u", ".bashrc"));
const fish = resolveShellRc("/usr/local/bin/fish", "/home/u");
expect(fish.kind).toBe("fish");
- expect(fish.rcPath).toBe("/home/u/.config/fish/config.fish");
+ expect(fish.rcPath).toBe(join("/home/u", ".config", "fish", "config.fish"));
expect(fish.body("en")).toBe("set -gx PENGUIN_LANG en");
});
it("falls back to ~/.profile for an unknown shell", () => {
const rc = resolveShellRc(undefined, "/home/u");
expect(rc.kind).toBe("unknown");
- expect(rc.rcPath).toBe("/home/u/.profile");
+ expect(rc.rcPath).toBe(join("/home/u", ".profile"));
});
});
diff --git a/packages/cli/test/update.test.ts b/packages/cli/test/update.test.ts
index 5194ec8..2fef819 100644
--- a/packages/cli/test/update.test.ts
+++ b/packages/cli/test/update.test.ts
@@ -22,58 +22,64 @@ import {
} from "../src/commands/update.js";
import { getMessages } from "../src/i18n.js";
-describe("detectInstall (how this CLI was installed, from its own real path)", () => {
- it("tarball: /lib/dist/index.js, the layout install.sh unpacks", () => {
- expect(detectInstall("/home/me/.penguin/lib/dist/index.js")).toEqual({
- kind: "tarball",
- installDir: "/home/me/.penguin",
+// POSIX-only: the fixtures are POSIX install layouts and detectInstall normalizes through
+// path.* (backslashes on Windows) — where in-place `penguin update` is refused anyway
+// (the documented Windows upgrade path is re-running install.ps1).
+describe.skipIf(process.platform === "win32")(
+ "detectInstall (how this CLI was installed, from its own real path)",
+ () => {
+ it("tarball: /lib/dist/index.js, the layout install.sh unpacks", () => {
+ expect(detectInstall("/home/me/.penguin/lib/dist/index.js")).toEqual({
+ kind: "tarball",
+ installDir: "/home/me/.penguin",
+ });
});
- });
- it("tarball: a non-default PENGUIN_INSTALL_DIR is read off the path, not the environment", () => {
- expect(detectInstall("/opt/tools/penguin/lib/dist/index.js")).toEqual({
- kind: "tarball",
- installDir: "/opt/tools/penguin",
+ it("tarball: a non-default PENGUIN_INSTALL_DIR is read off the path, not the environment", () => {
+ expect(detectInstall("/opt/tools/penguin/lib/dist/index.js")).toEqual({
+ kind: "tarball",
+ installDir: "/opt/tools/penguin",
+ });
});
- });
- it("npm global: npm's own prefix layout", () => {
- expect(
- detectInstall("/usr/local/lib/node_modules/@prismshadow/penguin-cli/dist/index.js"),
- ).toEqual({ kind: "npm", globalRoot: "/usr/local/lib/node_modules" });
- });
-
- it("npm global: pnpm's global store, through the .pnpm virtual dir", () => {
- const p =
- "/home/me/.local/share/pnpm/global/5/node_modules/.pnpm/@prismshadow+penguin-cli@0.1.1/node_modules/@prismshadow/penguin-cli/dist/index.js";
- const info = detectInstall(p);
- expect(info.kind).toBe("npm");
- expect(info.globalRoot).toContain(".pnpm");
- });
-
- it("source checkout: the built dist inside the monorepo", () => {
- expect(detectInstall("/home/me/code/penguin-harness/packages/cli/dist/index.js")).toEqual({
- kind: "source",
+ it("npm global: npm's own prefix layout", () => {
+ expect(
+ detectInstall("/usr/local/lib/node_modules/@prismshadow/penguin-cli/dist/index.js"),
+ ).toEqual({ kind: "npm", globalRoot: "/usr/local/lib/node_modules" });
});
- });
- it("source checkout: tsx running src directly", () => {
- expect(detectInstall("/home/me/code/penguin-harness/packages/cli/src/index.ts")).toEqual({
- kind: "source",
+ it("npm global: pnpm's global store, through the .pnpm virtual dir", () => {
+ const p =
+ "/home/me/.local/share/pnpm/global/5/node_modules/.pnpm/@prismshadow+penguin-cli@0.1.1/node_modules/@prismshadow/penguin-cli/dist/index.js";
+ const info = detectInstall(p);
+ expect(info.kind).toBe("npm");
+ expect(info.globalRoot).toContain(".pnpm");
});
- });
- it("a checkout wins over the tarball shape, so a repo under a lib/ dir is never mistaken for an install", () => {
- expect(detectInstall("/srv/lib/penguin-harness/packages/cli/dist/index.js")).toEqual({
- kind: "source",
+ it("source checkout: the built dist inside the monorepo", () => {
+ expect(detectInstall("/home/me/code/penguin-harness/packages/cli/dist/index.js")).toEqual({
+ kind: "source",
+ });
});
- });
- it("anything else is unknown rather than guessed", () => {
- expect(detectInstall("/random/place/index.js").kind).toBe("unknown");
- expect(detectInstall("/home/me/.penguin/bin/penguin").kind).toBe("unknown");
- });
-});
+ it("source checkout: tsx running src directly", () => {
+ expect(detectInstall("/home/me/code/penguin-harness/packages/cli/src/index.ts")).toEqual({
+ kind: "source",
+ });
+ });
+
+ it("a checkout wins over the tarball shape, so a repo under a lib/ dir is never mistaken for an install", () => {
+ expect(detectInstall("/srv/lib/penguin-harness/packages/cli/dist/index.js")).toEqual({
+ kind: "source",
+ });
+ });
+
+ it("anything else is unknown rather than guessed", () => {
+ expect(detectInstall("/random/place/index.js").kind).toBe("unknown");
+ expect(detectInstall("/home/me/.penguin/bin/penguin").kind).toBe("unknown");
+ });
+ },
+);
describe("detectPackageManager (which manager owns a global node_modules root)", () => {
it("pnpm: the global store or the .pnpm virtual dir", () => {
diff --git a/packages/core/package.json b/packages/core/package.json
index 4be59f9..96d4b08 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -36,7 +36,7 @@
"scripts": {
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "vitest run --passWithNoTests",
- "test:e2e": "PENGUIN_E2E=1 vitest run test/llm.e2e.test.ts",
+ "test:e2e": "node ../../scripts/run-with-env.mjs PENGUIN_E2E=1 -- vitest run test/llm.e2e.test.ts",
"build": "tsup"
},
"dependencies": {
diff --git a/packages/core/src/environment/tools/command/index.ts b/packages/core/src/environment/tools/command/index.ts
index d94f444..7c55bf4 100644
--- a/packages/core/src/environment/tools/command/index.ts
+++ b/packages/core/src/environment/tools/command/index.ts
@@ -4,6 +4,8 @@
export { CommandSessionManager } from "./session-manager.js";
export { ManagedSession, resultForExit } from "./session.js";
export type { ProcessExit, SpawnOptions } from "./session.js";
+export { resolveShell, sessionShell } from "./shell.js";
+export type { ShellInvocation, ResolveShellOptions } from "./shell.js";
export {
DEFAULT_EXEC_YIELD_MS,
DEFAULT_WRITE_YIELD_MS,
diff --git a/packages/core/src/environment/tools/command/session.ts b/packages/core/src/environment/tools/command/session.ts
index 6cf1dd4..92f7c49 100644
--- a/packages/core/src/environment/tools/command/session.ts
+++ b/packages/core/src/environment/tools/command/session.ts
@@ -1,11 +1,14 @@
/**
* ManagedSession — runtime state and collection logic for a single command session.
*
- * Spawns the process with `bash -lc `, with stdout/stderr going through plain pipes (no
+ * Spawns the process with `bash -lc ` (on Windows, the shell picked by `sessionShell()`
+ * — see shell.ts), with stdout/stderr going through plain pipes (no
* native dependency, clean output; an interactive program that detects no TTY falls back to
* non-interactive mode, which parses more cleanly for the Agent anyway). `detached` makes the
* child process the process-group leader, so both Ctrl-C and killing the whole group rely on
* **process-group signals** (sending a signal to `-pid` also reaches background child processes).
+ * Windows has neither process groups nor real signals: every "signal" degrades to a hard
+ * TerminateProcess, and tree-wide cleanup goes through `taskkill /t` instead (see signalGroup).
*
* Key semantics:
* - **Termination is determined by the foreground process exiting (the exit event, waitpid
@@ -22,9 +25,10 @@
* - `kill()` sends SIGTERM to the process group, then SIGKILL after a grace period, reaping any
* leftover background child processes; idempotent.
*/
-import { spawn, type ChildProcess } from "node:child_process";
+import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import type { ToolResult } from "../types.js";
import { CappedTextBuffer, WakeSignal } from "../background/index.js";
+import { sessionShell } from "./shell.js";
/** Process-group semantics are available on POSIX; Windows falls back to signaling the child process directly. */
const SUPPORTS_PROCESS_GROUP = process.platform !== "win32";
@@ -48,7 +52,7 @@ export interface ProcessExit {
/** Arguments required to start a command. */
export interface SpawnOptions {
- /** Command string handed to `bash -lc`. */
+ /** Command string handed to the session shell (`bash -lc` on POSIX; see shell.ts for Windows). */
cmd: string;
/** Working directory (absolute path). */
cwd: string;
@@ -71,11 +75,13 @@ export class ManagedSession {
private readonly wakeSignal = new WakeSignal();
constructor(opts: SpawnOptions) {
- this.child = spawn("bash", ["-lc", opts.cmd], {
+ const shell = sessionShell();
+ this.child = spawn(shell.command, [...shell.args, opts.cmd], {
cwd: opts.cwd,
env: opts.env,
detached: SUPPORTS_PROCESS_GROUP, // Become the process-group leader, so the whole group can be signaled
stdio: ["pipe", "pipe", "pipe"],
+ windowsHide: true, // No flashing console window on Windows (ignored elsewhere)
});
this.child.stdout?.setEncoding("utf8");
this.child.stderr?.setEncoding("utf8");
@@ -92,11 +98,26 @@ export class ManagedSession {
this.child.on("error", (err) => this.handleError(err));
}
- /** Signals the process group; ignores the case where the process/group has already exited (ESRCH). */
- private signalGroup(sig: NodeJS.Signals): void {
+ /**
+ * Signals the process group; ignores the case where the process/group has already exited (ESRCH).
+ *
+ * Windows has no signals: `child.kill()` is an unconditional TerminateProcess of the direct
+ * child only, which would orphan grandchildren (a `node server.js` started by the shell).
+ * Every signal therefore becomes a hard kill of the whole tree via `taskkill /t /f` — including
+ * SIGINT: without a shared console there is no way to deliver a real Ctrl-C to a piped child,
+ * so input_command's Ctrl-C degrades to this hard kill on Windows. `sync` uses spawnSync for
+ * the process-'exit' fallback, where the event loop is no longer running.
+ */
+ private signalGroup(sig: NodeJS.Signals, sync = false): void {
try {
if (SUPPORTS_PROCESS_GROUP && typeof this.child.pid === "number" && this.child.pid > 0) {
process.kill(-this.child.pid, sig); // Negative pid = the whole process group
+ } else if (
+ process.platform === "win32" &&
+ typeof this.child.pid === "number" &&
+ this.child.pid > 0
+ ) {
+ this.killTreeWindows(this.child.pid, sync);
} else {
this.child.kill(sig);
}
@@ -105,6 +126,26 @@ export class ManagedSession {
}
}
+ /** Hard-kills the whole process tree on Windows; falls back to child.kill() if taskkill itself fails. */
+ private killTreeWindows(pid: number, sync: boolean): void {
+ const args = ["/pid", String(pid), "/t", "/f"];
+ if (sync) {
+ const res = spawnSync("taskkill", args, { stdio: "ignore", windowsHide: true });
+ if (res.error || res.status !== 0) this.child.kill();
+ return;
+ }
+ try {
+ const killer = spawn("taskkill", args, { stdio: "ignore", windowsHide: true });
+ // taskkill missing or failing (e.g. restricted environment): still terminate the direct child.
+ killer.on("error", () => this.child.kill());
+ killer.on("exit", (code) => {
+ if (code !== 0 && !this.exited) this.child.kill();
+ });
+ } catch {
+ this.child.kill();
+ }
+ }
+
private handleData(chunk: string): void {
this.buffer.append(chunk);
this.wakeSignal.notify();
@@ -191,6 +232,7 @@ export class ManagedSession {
// stdin may already be closed, ignored.
}
}
+ /** Ctrl-C. POSIX: SIGINT to the process group; Windows: degrades to a hard tree kill (see signalGroup). */
interrupt(): void {
this.lastUsed = Date.now();
this.signalGroup("SIGINT");
@@ -214,7 +256,7 @@ export class ManagedSession {
clearTimeout(this.killTimer);
this.killTimer = null;
}
- this.signalGroup("SIGKILL");
+ this.signalGroup("SIGKILL", true);
}
}
diff --git a/packages/core/src/environment/tools/command/shell.ts b/packages/core/src/environment/tools/command/shell.ts
new file mode 100644
index 0000000..0a87054
--- /dev/null
+++ b/packages/core/src/environment/tools/command/shell.ts
@@ -0,0 +1,121 @@
+/**
+ * Shell selection for command sessions.
+ *
+ * POSIX behavior is unchanged: commands run via `bash -lc `. On Windows there is no
+ * bash by default, so the resolver picks the best available shell once per process:
+ *
+ * 1. `PENGUIN_SHELL` (explicit executable name or path) always wins, on every platform;
+ * the argument shape is inferred from its basename (see below).
+ * 2. Otherwise, non-Windows uses `bash -lc` (today's behavior, bit for bit).
+ * 3. On Windows, probe PATH for `bash` (Git for Windows — best compatibility with the
+ * skill/prompt ecosystem, which is written for a POSIX shell), then `pwsh`
+ * (PowerShell 7+), then fall back to `powershell` (Windows PowerShell 5.1, always
+ * present). A `bash` that resolves into the Windows system directory is ignored: that
+ * is the WSL launcher, which runs commands inside a Linux distro with a different
+ * filesystem view (and fails outright when no distro is configured).
+ *
+ * Argument shapes by basename (also applied to `PENGUIN_SHELL` values):
+ * - `pwsh` / `powershell` -> `-NoLogo -NoProfile -Command `
+ * - `cmd` -> `/d /s /c `
+ * - anything else -> `-lc ` (bash/zsh/sh-style login shell)
+ *
+ * The resolved shell's name is surfaced to the model via the session environment (the
+ * `Shell:` line in the system prompt), so it knows which syntax the exec tool speaks.
+ */
+import { spawnSync } from "node:child_process";
+import path from "node:path";
+
+/** A resolved shell invocation: `spawn(command, [...args, cmd])` runs `cmd` in that shell. */
+export interface ShellInvocation {
+ /** Executable name or absolute path handed to spawn(). */
+ command: string;
+ /** Fixed argument prefix placed before the command string. */
+ args: string[];
+ /** Short lowercase name (basename without extension), shown to the model (e.g. "bash", "pwsh"). */
+ name: string;
+}
+
+/** Injection points for unit tests; production callers use the defaults. */
+export interface ResolveShellOptions {
+ platform?: NodeJS.Platform;
+ env?: NodeJS.ProcessEnv;
+ /** Returns the PATH resolutions of an executable name, best match first ([] when not found). */
+ whichAll?: (cmd: string) => string[];
+ /** The Windows system root (to recognize the WSL bash launcher); default `env.SystemRoot` or C:\Windows. */
+ systemRoot?: string;
+}
+
+/** Basename without a trailing .exe/.cmd/.bat/.ps1 extension, lowercased ("C:\...\pwsh.EXE" -> "pwsh"). */
+function shellBasename(command: string): string {
+ // path.win32 handles both separators, so PENGUIN_SHELL=/usr/bin/zsh still yields "zsh".
+ return path.win32
+ .basename(command)
+ .replace(/\.(exe|cmd|bat|ps1)$/i, "")
+ .toLowerCase();
+}
+
+/** Argument prefix for a shell, chosen by its basename (PowerShell-style vs cmd vs POSIX-style). */
+function argsForShell(name: string): string[] {
+ if (name === "pwsh" || name === "powershell") return ["-NoLogo", "-NoProfile", "-Command"];
+ if (name === "cmd") return ["/d", "/s", "/c"];
+ return ["-lc"];
+}
+
+/** Default PATH probe: `where` lists every match line by line, in PATH order (win32 only). */
+function defaultWhichAll(cmd: string): string[] {
+ try {
+ const res = spawnSync("where", [cmd], {
+ stdio: ["ignore", "pipe", "ignore"],
+ windowsHide: true,
+ });
+ if (res.status !== 0 || !res.stdout) return [];
+ return res.stdout
+ .toString("utf8")
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0);
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Resolves the shell for command sessions (pure given its options; see the module comment
+ * for the order). Exported for unit tests; runtime code uses the cached `sessionShell()`.
+ */
+export function resolveShell(opts: ResolveShellOptions = {}): ShellInvocation {
+ const platform = opts.platform ?? process.platform;
+ const env = opts.env ?? process.env;
+
+ const explicit = env.PENGUIN_SHELL?.trim();
+ if (explicit) {
+ const name = shellBasename(explicit);
+ return { command: explicit, args: argsForShell(name), name };
+ }
+
+ if (platform !== "win32") {
+ return { command: "bash", args: ["-lc"], name: "bash" };
+ }
+
+ const whichAll = opts.whichAll ?? defaultWhichAll;
+ const systemRoot = opts.systemRoot ?? env.SystemRoot ?? "C:\\Windows";
+ // The WSL launcher lives in \System32 (or Sysnative under WOW64); a Git for
+ // Windows bash lives under the Git install dir. Only the first PATH match counts — that
+ // is the one spawn("bash") would run.
+ const bash = whichAll("bash")[0];
+ if (bash && !bash.toLowerCase().startsWith(systemRoot.toLowerCase() + path.win32.sep)) {
+ return { command: "bash", args: ["-lc"], name: "bash" };
+ }
+ if (whichAll("pwsh").length > 0) {
+ return { command: "pwsh", args: argsForShell("pwsh"), name: "pwsh" };
+ }
+ return { command: "powershell", args: argsForShell("powershell"), name: "powershell" };
+}
+
+let cached: ShellInvocation | null = null;
+
+/** The process-wide shell for command sessions; resolved once (probing PATH costs a subprocess on Windows). */
+export function sessionShell(): ShellInvocation {
+ if (!cached) cached = resolveShell();
+ return cached;
+}
diff --git a/packages/core/src/internal/session-support.ts b/packages/core/src/internal/session-support.ts
index 40fdaec..4fd29f8 100644
--- a/packages/core/src/internal/session-support.ts
+++ b/packages/core/src/internal/session-support.ts
@@ -9,6 +9,7 @@ import path from "node:path";
import { randomBytes, randomUUID } from "node:crypto";
import { formatLocalDate } from "./dates.js";
+import { sessionShell } from "../environment/tools/command/shell.js";
import type { SessionEnvironmentValues } from "../state/agent-state.js";
import { workspacesDir } from "../state/index.js";
import { userText } from "../omnimessage/index.js";
@@ -47,6 +48,9 @@ export function sessionEnvironment(
modelId: ids.modelId,
platform: process.platform,
osVersion: getOsVersion(),
+ // The shell exec_command actually runs (bash on POSIX; resolved on Windows): the model
+ // must know whether to write bash or PowerShell syntax.
+ shell: sessionShell().name,
date: formatLocalDate(date),
};
}
diff --git a/packages/core/src/state/agent-state.ts b/packages/core/src/state/agent-state.ts
index 0a74f5b..f3a95e8 100644
--- a/packages/core/src/state/agent-state.ts
+++ b/packages/core/src/state/agent-state.ts
@@ -36,6 +36,7 @@ import {
PLATFORM_PLACEHOLDER,
PROJECT_DIR_PLACEHOLDER,
SESSION_ID_PLACEHOLDER,
+ SHELL_PLACEHOLDER,
type SystemConfig,
} from "./default-config.js";
import { builtinProjectAgentPresets, type AgentPreset } from "./builtin-agents.js";
@@ -92,6 +93,8 @@ export interface SessionEnvironmentValues {
modelId: string;
platform: string;
osVersion: string;
+ /** The shell command sessions run in (system Prompt placeholder {{SHELL}}; e.g. "bash", "pwsh") — tells the model which command syntax exec_command speaks. */
+ shell: string;
date: string;
}
@@ -383,6 +386,43 @@ export function skillMetadataSection(skills: SkillMetadata[]): string {
.join("\n");
}
+/**
+ * Windows compatibility fallback for pre-`{{SHELL}}` system prompt templates.
+ *
+ * `system_config.yaml` is baked at Agent creation and never auto-upgraded, so an Agent created
+ * before the `{{SHELL}}` placeholder existed never tells its model which shell `exec_command`
+ * speaks — and on Windows (where the shell may be PowerShell, not bash) the model then keeps
+ * emitting bash syntax into the wrong shell. When the platform is win32 and the template carries
+ * no `{{SHELL}}` placeholder, inject a `- Shell: ` line into the assembled prompt at
+ * render time: right after the Environment section heading when one exists, else appended as a
+ * minimal final line. In-memory only — the stored template is never rewritten. On POSIX the
+ * assembled prompt stays byte-identical (bash was always implied there), and a prompt that
+ * already carries the exact line is left untouched (idempotent).
+ *
+ * Retirement condition: remove this fallback once pre-`{{SHELL}}` Agent configs (created before
+ * the placeholder shipped in PR #79) are no longer expected in the wild.
+ */
+function withShellLineFallback(
+ assembled: string,
+ template: string,
+ sessionEnvironment?: SessionEnvironmentValues,
+): string {
+ if (sessionEnvironment?.platform !== "win32") return assembled;
+ if (!sessionEnvironment.shell) return assembled;
+ if (template.includes(SHELL_PLACEHOLDER)) return assembled; // The template already renders the line itself.
+ // Any existing `- Shell:` line means the model is already told a shell — including a custom
+ // template hardcoding a different value on purpose; never add a second, contradicting line.
+ if (/^- Shell: /m.test(assembled)) return assembled;
+ const line = `- Shell: ${sessionEnvironment.shell}`;
+ // The default templates head the section with `# Environment`; accept any heading level.
+ const heading = /^#+ Environment[ \t]*$/m.exec(assembled);
+ if (heading) {
+ const insertAt = heading.index + heading[0].length;
+ return `${assembled.slice(0, insertAt)}\n${line}${assembled.slice(insertAt)}`;
+ }
+ return `${assembled}\n${line}`;
+}
+
/**
* Renders the complete runtime system Prompt: substitutes `AGENTS.md`, vault key names, Skill
* metadata, and the concrete Session runtime environment placeholders into the system Prompt
@@ -390,7 +430,8 @@ export function skillMetadataSection(skills: SkillMetadata[]): string {
* wrapper text such as `[developer_instructions]` and the # Vault / # Skills statements are
* written directly into the system Prompt template itself (the Prompt is fully
* transparent and editable via `system_config.yaml`). Other files in Agent State / Workspace are
- * never auto-injected.
+ * never auto-injected. Sole exception: on win32 a template without `{{SHELL}}` gets a `- Shell:`
+ * line injected at render time (see `withShellLineFallback`).
*
* `{{VAULT_KEYS}}` is replaced with the vault key-name list (an empty string if empty/not
* provided): this lets the model know which APIs requiring a key it can call; values are never
@@ -406,7 +447,8 @@ export function assembleSystemPrompt(
vaultKeys?: string[],
skillMetadata?: SkillMetadata[],
): string {
- return state.systemConfig.system_prompt
+ const template = state.systemConfig.system_prompt;
+ const assembled = template
.split(AGENTS_MD_PLACEHOLDER)
.join(state.agentsMd.trim())
.split(VAULT_KEYS_PLACEHOLDER)
@@ -429,9 +471,12 @@ export function assembleSystemPrompt(
.join(sessionEnvironment?.platform ?? "")
.split(OS_VERSION_PLACEHOLDER)
.join(sessionEnvironment?.osVersion ?? "")
+ .split(SHELL_PLACEHOLDER)
+ .join(sessionEnvironment?.shell ?? "")
.split(DATE_PLACEHOLDER)
.join(sessionEnvironment?.date ?? "")
.trim();
+ return withShellLineFallback(assembled, template, sessionEnvironment);
}
/**
diff --git a/packages/core/src/state/default-config.ts b/packages/core/src/state/default-config.ts
index c68d1a7..2857dba 100644
--- a/packages/core/src/state/default-config.ts
+++ b/packages/core/src/state/default-config.ts
@@ -33,6 +33,8 @@ export const PROVIDER_PLACEHOLDER = "{{PROVIDER}}";
export const MODEL_ID_PLACEHOLDER = "{{MODEL_ID}}";
export const PLATFORM_PLACEHOLDER = "{{PLATFORM}}";
export const OS_VERSION_PLACEHOLDER = "{{OS_VERSION}}";
+/** The shell exec_command runs (`bash` on POSIX; on Windows whatever shell.ts resolved), so the model knows which command syntax to write. */
+export const SHELL_PLACEHOLDER = "{{SHELL}}";
export const DATE_PLACEHOLDER = "{{DATE}}";
/**
@@ -147,6 +149,7 @@ Skills are reusable instruction packages stored under /agents/ {
modelId: "deepseek-v4-pro",
platform: "linux",
osVersion: "test",
+ shell: "bash",
date: "2026-07-08",
});
expect(prompt).toContain("Agent ID: env_agent");
diff --git a/packages/core/test/engine.test.ts b/packages/core/test/engine.test.ts
index 0123f4c..eece206 100644
--- a/packages/core/test/engine.test.ts
+++ b/packages/core/test/engine.test.ts
@@ -132,8 +132,10 @@ describe("ContextEngine ReAct loop (mock LLM, approve callback)", () => {
});
afterEach(async () => {
- await rm(workspace, { recursive: true, force: true });
- await rm(traces, { recursive: true, force: true });
+ // Retries (here and in the other cleanups below): on Windows a just-killed process tree
+ // releases its cwd locks asynchronously, so an immediate rm can hit EBUSY.
+ await rm(workspace, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
+ await rm(traces, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
});
it("approves a tool call, writes the file, returns the final answer, traces it", async () => {
@@ -733,7 +735,7 @@ describe("ContextEngine async/incremental tool calls (overlapping execution)", (
workspace = await mkdtemp(join(tmpdir(), "penguin-ws2-"));
});
afterEach(async () => {
- await rm(workspace, { recursive: true, force: true });
+ await rm(workspace, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
});
it("emits both tool calls in one round; second is approved while the first executes; outputs come back in completion order", async () => {
@@ -809,7 +811,13 @@ describe("ContextEngine async/incremental tool calls (overlapping execution)", (
// command has not finished yet) -- i.e., execution does not block the next approval.
expect(approvedAt["t2"]!).toBeLessThan(firstCompleteAt["t1"] ?? Infinity);
// The fast b.txt finishes first, the slow a.txt finishes later (outputs in completion order).
- expect(firstCompleteAt["t2"]!).toBeLessThan(firstCompleteAt["t1"]!);
+ // POSIX only: on Windows a cold Git-Bash spawn costs 1-2s, which can swamp the 400ms sleep
+ // delta that makes t1 "the slow one" — CI has seen the two complete within 5ms — so the
+ // relative completion order is not controllable there. The overlap assertion above and the
+ // file contents below still run on Windows.
+ if (process.platform !== "win32") {
+ expect(firstCompleteAt["t2"]!).toBeLessThan(firstCompleteAt["t1"]!);
+ }
expect(await readFile(join(workspace, "a.txt"), "utf8")).toBe("one");
expect(await readFile(join(workspace, "b.txt"), "utf8")).toBe("two");
@@ -895,7 +903,7 @@ describe("ContextEngine tool execution resilience", () => {
workspace = await mkdtemp(join(tmpdir(), "penguin-ws4-"));
});
afterEach(async () => {
- await rm(workspace, { recursive: true, force: true });
+ await rm(workspace, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
});
it("feeds a failed tool output back and keeps tool_use/result paired (Environment converges errors, never throws)", async () => {
@@ -1105,7 +1113,7 @@ describe("ContextEngine abort during execution", () => {
workspace = await mkdtemp(join(tmpdir(), "penguin-ws3-"));
});
afterEach(async () => {
- await rm(workspace, { recursive: true, force: true });
+ await rm(workspace, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
});
it("aborting a long-running tool ends the turn, emits abort, and carries tool results over (model output completed)", async () => {
@@ -1255,7 +1263,7 @@ describe("ContextEngine LLM timeout / network interruption (PRN-012)", () => {
workspace = await mkdtemp(join(tmpdir(), "penguin-ws4-"));
});
afterEach(async () => {
- await rm(workspace, { recursive: true, force: true });
+ await rm(workspace, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
});
it("auto-retries on LLM timeout: original input + [turn_retried] carrying partial products", async () => {
@@ -1792,8 +1800,8 @@ describe("ContextEngine mid-run steering ([user_steering])", () => {
});
afterEach(async () => {
- await rm(workspace, { recursive: true, force: true });
- await rm(traces, { recursive: true, force: true });
+ await rm(workspace, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
+ await rm(traces, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
});
/** Fake environment: streams a delta then closes with a fixed complete output (no real shell). */
diff --git a/packages/core/test/environment.test.ts b/packages/core/test/environment.test.ts
index 1b3e835..8f90b99 100644
--- a/packages/core/test/environment.test.ts
+++ b/packages/core/test/environment.test.ts
@@ -67,7 +67,9 @@ beforeEach(async () => {
afterEach(async () => {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
- await rm(tmp, { recursive: true, force: true });
+ // Retries: on Windows a just-killed process tree releases its cwd/file locks asynchronously,
+ // so an immediate recursive rm can hit EBUSY; fs.rm retries those with a linear backoff.
+ await rm(tmp, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
});
describe("Environment.listTools", () => {
@@ -485,9 +487,12 @@ describe("Environment.executeTool — relaxed tool contract", () => {
describe("Environment.executeTool — timeoutMs (PRN-013)", () => {
it("fails a tool exceeding timeoutMs, keeps prior output, and streams the timeout reason", async () => {
+ // The timeout must stay below MIN_YIELD_MS (250): for larger values exec_command yields to
+ // background (with a process_id) before the Environment timeout can ever fire.
+ const timeoutMs = 200;
const env = new Environment({
workspaceDir: tmp,
- toolConfig: makeToolConfig(execTool({ timeoutMs: 200 })),
+ toolConfig: makeToolConfig(execTool({ timeoutMs })),
});
const startedAt = Date.now();
@@ -513,8 +518,13 @@ describe("Environment.executeTool — timeoutMs (PRN-013)", () => {
};
expect(last.type).toBe("tool_call_output");
expect(last.stop_reason).toBe("failed");
- expect(last.output).toContain("begin");
- expect(last.output).toContain("[tool timeout: exceeded 200ms]");
+ // Kept-prior-output is asserted only where the shell can win the race: a Git-Bash login
+ // shell on Windows needs several hundred ms to start, so nothing is printed before a
+ // sub-250ms timeout there — the timeout mechanics above are still fully exercised.
+ if (process.platform !== "win32") {
+ expect(last.output).toContain("begin");
+ }
+ expect(last.output).toContain(`[tool timeout: exceeded ${timeoutMs}ms]`);
// The timeout marker is also produced via streaming: concatenating the streamed deltas ==
// the complete content.
const streamed = messages
@@ -735,14 +745,14 @@ describe("Environment.executeTool — robustness", () => {
describe("Environment.toolPermission", () => {
it("returns the configured permission for a known tool", () => {
const env = new Environment({
- workspaceDir: "/tmp",
+ workspaceDir: tmpdir(),
toolConfig: makeToolConfig(execTool({ permission: "rw" })),
});
expect(env.toolPermission("exec_command")).toBe("rw");
});
it("returns undefined for an unknown tool", () => {
- const env = new Environment({ workspaceDir: "/tmp", toolConfig: makeToolConfig() });
+ const env = new Environment({ workspaceDir: tmpdir(), toolConfig: makeToolConfig() });
expect(env.toolPermission("nope")).toBeUndefined();
});
});
diff --git a/packages/core/test/exec-session.test.ts b/packages/core/test/exec-session.test.ts
index bf379e6..200e7da 100644
--- a/packages/core/test/exec-session.test.ts
+++ b/packages/core/test/exec-session.test.ts
@@ -91,7 +91,9 @@ beforeEach(async () => {
afterEach(async () => {
env.dispose();
- await rm(tmp, { recursive: true, force: true });
+ // Retries: on Windows a just-killed process tree releases its cwd/file locks asynchronously,
+ // so an immediate recursive rm can hit EBUSY; fs.rm retries those with a linear backoff.
+ await rm(tmp, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
});
describe("exec_command — long-running command sessions", () => {
diff --git a/packages/core/test/file-tools.test.ts b/packages/core/test/file-tools.test.ts
index f15445a..e12292b 100644
--- a/packages/core/test/file-tools.test.ts
+++ b/packages/core/test/file-tools.test.ts
@@ -514,15 +514,19 @@ describe("edit_file — review follow-ups", () => {
describe("write_file — review follow-ups", () => {
const tool = () => createWriteFileTool(def(WRITE_FILE_NAME, "rw"));
- it("preserves the permission bits of an overwritten file (atomic rename)", async () => {
- const file = path.join(tmp, "mode.txt");
- await writeFile(file, "old");
- await chmod(file, 0o600);
- const { result } = await run(tool(), { file_path: "mode.txt", content: "new" }, tmp);
- expect(result?.stopReason).toBeUndefined();
- expect((await stat(file)).mode & 0o777).toBe(0o600);
- expect(await readFile(file, "utf8")).toBe("new");
- });
+ // POSIX-only: Windows has no owner-only mode bits to preserve (chmod maps to the read-only attribute).
+ it.skipIf(process.platform === "win32")(
+ "preserves the permission bits of an overwritten file (atomic rename)",
+ async () => {
+ const file = path.join(tmp, "mode.txt");
+ await writeFile(file, "old");
+ await chmod(file, 0o600);
+ const { result } = await run(tool(), { file_path: "mode.txt", content: "new" }, tmp);
+ expect(result?.stopReason).toBeUndefined();
+ expect((await stat(file)).mode & 0o777).toBe(0o600);
+ expect(await readFile(file, "utf8")).toBe("new");
+ },
+ );
it("writes atomically: no temp files are left behind", async () => {
await run(tool(), { file_path: "fresh.txt", content: "x" }, tmp);
diff --git a/packages/core/test/shell-resolver.test.ts b/packages/core/test/shell-resolver.test.ts
new file mode 100644
index 0000000..2a0bdbb
--- /dev/null
+++ b/packages/core/test/shell-resolver.test.ts
@@ -0,0 +1,112 @@
+/**
+ * Unit tests for the command-session shell resolver (pure function; platform, env and the
+ * PATH probe are injected — no real shells are spawned here).
+ */
+import { describe, expect, it } from "vitest";
+import { resolveShell } from "../src/environment/tools/command/shell.js";
+
+const POWERSHELL_ARGS = ["-NoLogo", "-NoProfile", "-Command"];
+
+/** A whichAll stub resolving only the given names (value = returned PATH matches). */
+function which(table: Record): (cmd: string) => string[] {
+ return (cmd) => table[cmd] ?? [];
+}
+
+describe("resolveShell — POSIX", () => {
+ it("uses bash -lc on linux without probing (today's behavior, unchanged)", () => {
+ let probed = false;
+ const shell = resolveShell({
+ platform: "linux",
+ env: {},
+ whichAll: () => {
+ probed = true;
+ return [];
+ },
+ });
+ expect(shell).toEqual({ command: "bash", args: ["-lc"], name: "bash" });
+ expect(probed).toBe(false);
+ });
+
+ it("uses bash -lc on darwin", () => {
+ const shell = resolveShell({ platform: "darwin", env: {} });
+ expect(shell).toEqual({ command: "bash", args: ["-lc"], name: "bash" });
+ });
+});
+
+describe("resolveShell — win32 probing", () => {
+ it("prefers bash on PATH (Git for Windows)", () => {
+ const shell = resolveShell({
+ platform: "win32",
+ env: {},
+ whichAll: which({
+ bash: ["C:\\Program Files\\Git\\bin\\bash.exe"],
+ pwsh: ["C:\\Program Files\\PowerShell\\7\\pwsh.exe"],
+ }),
+ });
+ expect(shell).toEqual({ command: "bash", args: ["-lc"], name: "bash" });
+ });
+
+ it("skips the WSL launcher bash under the system root and falls through to pwsh", () => {
+ const shell = resolveShell({
+ platform: "win32",
+ env: { SystemRoot: "C:\\WINDOWS" },
+ whichAll: which({
+ bash: ["C:\\Windows\\System32\\bash.exe"],
+ pwsh: ["C:\\Program Files\\PowerShell\\7\\pwsh.exe"],
+ }),
+ });
+ expect(shell).toEqual({ command: "pwsh", args: POWERSHELL_ARGS, name: "pwsh" });
+ });
+
+ it("falls back to pwsh when bash is absent", () => {
+ const shell = resolveShell({
+ platform: "win32",
+ env: {},
+ whichAll: which({ pwsh: ["C:\\Program Files\\PowerShell\\7\\pwsh.exe"] }),
+ });
+ expect(shell).toEqual({ command: "pwsh", args: POWERSHELL_ARGS, name: "pwsh" });
+ });
+
+ it("falls back to powershell when neither bash nor pwsh resolve", () => {
+ const shell = resolveShell({ platform: "win32", env: {}, whichAll: which({}) });
+ expect(shell).toEqual({ command: "powershell", args: POWERSHELL_ARGS, name: "powershell" });
+ });
+});
+
+describe("resolveShell — PENGUIN_SHELL override", () => {
+ it("wins on every platform and keeps POSIX-style args for a POSIX shell path", () => {
+ const shell = resolveShell({
+ platform: "linux",
+ env: { PENGUIN_SHELL: "/usr/bin/zsh" },
+ });
+ expect(shell).toEqual({ command: "/usr/bin/zsh", args: ["-lc"], name: "zsh" });
+ });
+
+ it("uses PowerShell-style args when the basename is pwsh (case/extension-insensitive)", () => {
+ const shell = resolveShell({
+ platform: "win32",
+ env: { PENGUIN_SHELL: "C:\\Program Files\\PowerShell\\7\\pwsh.EXE" },
+ whichAll: which({ bash: ["C:\\Program Files\\Git\\bin\\bash.exe"] }),
+ });
+ expect(shell).toEqual({
+ command: "C:\\Program Files\\PowerShell\\7\\pwsh.EXE",
+ args: POWERSHELL_ARGS,
+ name: "pwsh",
+ });
+ });
+
+ it("uses PowerShell-style args for a bare powershell name", () => {
+ const shell = resolveShell({ platform: "win32", env: { PENGUIN_SHELL: "powershell" } });
+ expect(shell).toEqual({ command: "powershell", args: POWERSHELL_ARGS, name: "powershell" });
+ });
+
+ it("uses cmd-style args when the basename is cmd", () => {
+ const shell = resolveShell({ platform: "win32", env: { PENGUIN_SHELL: "cmd" } });
+ expect(shell).toEqual({ command: "cmd", args: ["/d", "/s", "/c"], name: "cmd" });
+ });
+
+ it("ignores a blank PENGUIN_SHELL", () => {
+ const shell = resolveShell({ platform: "linux", env: { PENGUIN_SHELL: " " } });
+ expect(shell).toEqual({ command: "bash", args: ["-lc"], name: "bash" });
+ });
+});
diff --git a/packages/core/test/state.test.ts b/packages/core/test/state.test.ts
index d3fa9b5..e0b9c53 100644
--- a/packages/core/test/state.test.ts
+++ b/packages/core/test/state.test.ts
@@ -16,6 +16,7 @@ import {
PLATFORM_PLACEHOLDER,
PROJECT_DIR_PLACEHOLDER,
SESSION_ID_PLACEHOLDER,
+ SHELL_PLACEHOLDER,
addModel,
setVisionModel,
agentsMdPath,
@@ -64,7 +65,10 @@ afterEach(async () => {
} else {
process.env.PENGUIN_HOME = prevHome;
}
- await fs.rm(tmpRoot, { recursive: true, force: true });
+ // Retries: when a test times out, vitest runs this cleanup while the test's un-cancelled
+ // init may still be writing files, so an immediate recursive rm can hit ENOTEMPTY on
+ // Windows (fs.rm retries ENOTEMPTY/EBUSY/EPERM); a no-op when removal succeeds first try.
+ await fs.rm(tmpRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
});
async function exists(p: string): Promise {
@@ -83,87 +87,96 @@ describe("paths / resolveRoot", () => {
});
describe("loadOrInitAgentState", () => {
- it("initializes an empty agent directory with the full state layout", async () => {
- const state = await loadOrInitAgentState();
- expect(state.root).toBe(tmpRoot);
- expect(state.projectId).toBe(DEFAULT_PROJECT_ID);
- expect(state.agentId).toBe(DEFAULT_AGENT_ID);
+ // Timeout: initialization writes the full layout — 15 library skills plus the example
+ // benchmark, dozens of small files — and this first init test also pays the cold-I/O cost
+ // (first-touch reads of the skills package, Defender scans) on Windows runners, where a
+ // slow-disk moment has pushed it past the 5s default. Purely a failure deadline: passing
+ // runs stay as fast as before on every platform.
+ it(
+ "initializes an empty agent directory with the full state layout",
+ { timeout: 20_000 },
+ async () => {
+ const state = await loadOrInitAgentState();
+ expect(state.root).toBe(tmpRoot);
+ expect(state.projectId).toBe(DEFAULT_PROJECT_ID);
+ expect(state.agentId).toBe(DEFAULT_AGENT_ID);
- const root = tmpRoot;
- expect(await exists(systemConfigPath(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
- expect(await exists(agentsMdPath(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
- expect(await exists(toolsDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
- expect(await exists(memoryDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
- expect(await exists(skillsDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
- // The scratchpad/ directory alongside agent_state (model temp files get a subdirectory per Session id).
- expect(await exists(scratchpadDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
+ const root = tmpRoot;
+ expect(await exists(systemConfigPath(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
+ expect(await exists(agentsMdPath(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
+ expect(await exists(toolsDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
+ expect(await exists(memoryDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
+ expect(await exists(skillsDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
+ // The scratchpad/ directory alongside agent_state (model temp files get a subdirectory per Session id).
+ expect(await exists(scratchpadDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
- expect(state.stateDir).toBe(agentStateDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID));
+ expect(state.stateDir).toBe(agentStateDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID));
- // The default system Prompt states the Agent's identity, without repeating tool details
- // already in the tool schema (Suggested workflows only points to the run_subagent
- // delegation entry point).
- expect(state.systemConfig.system_prompt).toContain("PenguinHarness");
- expect(state.systemConfig.system_prompt).not.toContain("exec_command");
- // Suggested workflows absorbs Subagent delegation and task conventions (self-reported
- // identity as a soft convention, parallelism, file exchange).
- expect(state.systemConfig.system_prompt).toContain("# Suggested workflows");
- expect(state.systemConfig.system_prompt).toContain("run_subagent");
- expect(state.systemConfig.system_prompt).toContain("Caller agent");
- // The default AGENTS.md is empty: it carries no preset guidance.
- expect(state.agentsMd).toBe("");
- expect(state.systemConfig.system_prompt).toContain(AGENTS_MD_PLACEHOLDER);
- expect(state.systemConfig.system_prompt).toContain(SESSION_ID_PLACEHOLDER);
- expect(state.systemConfig.system_prompt).toContain(CWD_PLACEHOLDER);
- expect(state.systemConfig.system_prompt).toContain(PLATFORM_PLACEHOLDER);
- expect(state.systemConfig.system_prompt).toContain(OS_VERSION_PLACEHOLDER);
- expect(state.systemConfig.system_prompt).toContain(DATE_PLACEHOLDER);
- // AGENTS.md and the Environment injection sit at the end of the template, with AGENTS.md
- // before Environment; the [developer_instructions] wrapper text is written directly into
- // the template (the Prompt is transparent about the config).
- expect(state.systemConfig.system_prompt).toContain("[developer_instructions]");
- expect(state.systemConfig.system_prompt).toContain("[/developer_instructions]");
- // The default template explains the semantics of system-synthesized markers to the model,
- // and recommends preferring tool use.
- expect(state.systemConfig.system_prompt).toContain("[turn_aborted]");
- expect(state.systemConfig.system_prompt).toContain("[turn_retried]");
- expect(state.systemConfig.system_prompt).toContain("[context_summary]");
- expect(state.systemConfig.system_prompt).toContain("[user_steering]");
- expect(state.systemConfig.system_prompt).toContain("# Tool use");
- // Privacy hardening: explicitly forbids reading .project_config.toml (the sole config file,
- // which holds API keys) and each Agent's .vault.toml, and states that config can only be
- // changed via the CLI (penguin config ...).
- expect(state.systemConfig.system_prompt).toContain("Never read");
- expect(state.systemConfig.system_prompt).toContain(".project_config.toml");
- expect(state.systemConfig.system_prompt).toContain("agent_state/.vault.toml");
- expect(state.systemConfig.system_prompt).toContain("CLI-only");
- expect(state.systemConfig.system_prompt).toContain("penguin config");
- expect(state.systemConfig.system_prompt).not.toContain(".credentials.toml");
- expect(state.systemConfig.system_prompt.indexOf(AGENTS_MD_PLACEHOLDER)).toBeLessThan(
- state.systemConfig.system_prompt.indexOf("# Environment"),
- );
- // The # Vault and # Skills body sections plus their placeholders: the default template
- // places them after [/developer_instructions] and before # Environment, in the order
- // Vault -> Skills (the statement text is part of the template body, kept even with no
- // keys/skills).
- const tpl = state.systemConfig.system_prompt;
- expect(tpl).toContain("# Vault");
- expect(tpl).toContain(VAULT_KEYS_PLACEHOLDER);
- expect(tpl).toContain("# Skills");
- expect(tpl).toContain(SKILL_METADATA_PLACEHOLDER);
- expect(tpl).toContain("[use_skills]");
- expect(tpl.indexOf("[/developer_instructions]")).toBeLessThan(tpl.indexOf("# Vault"));
- expect(tpl.indexOf("# Vault")).toBeLessThan(tpl.indexOf(VAULT_KEYS_PLACEHOLDER));
- expect(tpl.indexOf(VAULT_KEYS_PLACEHOLDER)).toBeLessThan(tpl.indexOf("# Skills"));
- expect(tpl.indexOf("# Skills")).toBeLessThan(tpl.indexOf(SKILL_METADATA_PLACEHOLDER));
- expect(tpl.indexOf(SKILL_METADATA_PLACEHOLDER)).toBeLessThan(tpl.indexOf("# Environment"));
- expect(state.systemConfig.model?.max_tokens).toBe(32000);
- expect(state.systemConfig.model?.thinking_level).toBe("medium");
- expect(state.systemConfig.model?.timeoutMs).toBe(120000);
- expect(state.systemConfig.tools?.mcpServers).toEqual([]);
- expect(Object.hasOwn(state.systemConfig, "description")).toBe(false);
- expect(Object.hasOwn(state.systemConfig, "subagents")).toBe(false);
- });
+ // The default system Prompt states the Agent's identity, without repeating tool details
+ // already in the tool schema (Suggested workflows only points to the run_subagent
+ // delegation entry point).
+ expect(state.systemConfig.system_prompt).toContain("PenguinHarness");
+ expect(state.systemConfig.system_prompt).not.toContain("exec_command");
+ // Suggested workflows absorbs Subagent delegation and task conventions (self-reported
+ // identity as a soft convention, parallelism, file exchange).
+ expect(state.systemConfig.system_prompt).toContain("# Suggested workflows");
+ expect(state.systemConfig.system_prompt).toContain("run_subagent");
+ expect(state.systemConfig.system_prompt).toContain("Caller agent");
+ // The default AGENTS.md is empty: it carries no preset guidance.
+ expect(state.agentsMd).toBe("");
+ expect(state.systemConfig.system_prompt).toContain(AGENTS_MD_PLACEHOLDER);
+ expect(state.systemConfig.system_prompt).toContain(SESSION_ID_PLACEHOLDER);
+ expect(state.systemConfig.system_prompt).toContain(CWD_PLACEHOLDER);
+ expect(state.systemConfig.system_prompt).toContain(PLATFORM_PLACEHOLDER);
+ expect(state.systemConfig.system_prompt).toContain(OS_VERSION_PLACEHOLDER);
+ expect(state.systemConfig.system_prompt).toContain(DATE_PLACEHOLDER);
+ // AGENTS.md and the Environment injection sit at the end of the template, with AGENTS.md
+ // before Environment; the [developer_instructions] wrapper text is written directly into
+ // the template (the Prompt is transparent about the config).
+ expect(state.systemConfig.system_prompt).toContain("[developer_instructions]");
+ expect(state.systemConfig.system_prompt).toContain("[/developer_instructions]");
+ // The default template explains the semantics of system-synthesized markers to the model,
+ // and recommends preferring tool use.
+ expect(state.systemConfig.system_prompt).toContain("[turn_aborted]");
+ expect(state.systemConfig.system_prompt).toContain("[turn_retried]");
+ expect(state.systemConfig.system_prompt).toContain("[context_summary]");
+ expect(state.systemConfig.system_prompt).toContain("[user_steering]");
+ expect(state.systemConfig.system_prompt).toContain("# Tool use");
+ // Privacy hardening: explicitly forbids reading .project_config.toml (the sole config file,
+ // which holds API keys) and each Agent's .vault.toml, and states that config can only be
+ // changed via the CLI (penguin config ...).
+ expect(state.systemConfig.system_prompt).toContain("Never read");
+ expect(state.systemConfig.system_prompt).toContain(".project_config.toml");
+ expect(state.systemConfig.system_prompt).toContain("agent_state/.vault.toml");
+ expect(state.systemConfig.system_prompt).toContain("CLI-only");
+ expect(state.systemConfig.system_prompt).toContain("penguin config");
+ expect(state.systemConfig.system_prompt).not.toContain(".credentials.toml");
+ expect(state.systemConfig.system_prompt.indexOf(AGENTS_MD_PLACEHOLDER)).toBeLessThan(
+ state.systemConfig.system_prompt.indexOf("# Environment"),
+ );
+ // The # Vault and # Skills body sections plus their placeholders: the default template
+ // places them after [/developer_instructions] and before # Environment, in the order
+ // Vault -> Skills (the statement text is part of the template body, kept even with no
+ // keys/skills).
+ const tpl = state.systemConfig.system_prompt;
+ expect(tpl).toContain("# Vault");
+ expect(tpl).toContain(VAULT_KEYS_PLACEHOLDER);
+ expect(tpl).toContain("# Skills");
+ expect(tpl).toContain(SKILL_METADATA_PLACEHOLDER);
+ expect(tpl).toContain("[use_skills]");
+ expect(tpl.indexOf("[/developer_instructions]")).toBeLessThan(tpl.indexOf("# Vault"));
+ expect(tpl.indexOf("# Vault")).toBeLessThan(tpl.indexOf(VAULT_KEYS_PLACEHOLDER));
+ expect(tpl.indexOf(VAULT_KEYS_PLACEHOLDER)).toBeLessThan(tpl.indexOf("# Skills"));
+ expect(tpl.indexOf("# Skills")).toBeLessThan(tpl.indexOf(SKILL_METADATA_PLACEHOLDER));
+ expect(tpl.indexOf(SKILL_METADATA_PLACEHOLDER)).toBeLessThan(tpl.indexOf("# Environment"));
+ expect(state.systemConfig.model?.max_tokens).toBe(32000);
+ expect(state.systemConfig.model?.thinking_level).toBe("medium");
+ expect(state.systemConfig.model?.timeoutMs).toBe(120000);
+ expect(state.systemConfig.tools?.mcpServers).toEqual([]);
+ expect(Object.hasOwn(state.systemConfig, "description")).toBe(false);
+ expect(Object.hasOwn(state.systemConfig, "subagents")).toBe(false);
+ },
+ );
it("loads an existing agent directory and returns the same system prompt", async () => {
const first = await loadOrInitAgentState();
@@ -500,6 +513,7 @@ describe("assembleSystemPrompt", () => {
`pdir=${PROJECT_DIR_PLACEHOLDER}`,
`platform=${PLATFORM_PLACEHOLDER}`,
`os=${OS_VERSION_PLACEHOLDER}`,
+ `shell=${SHELL_PLACEHOLDER}`,
`date=${DATE_PLACEHOLDER}`,
"middle",
AGENTS_MD_PLACEHOLDER,
@@ -518,6 +532,7 @@ describe("assembleSystemPrompt", () => {
modelId: "deepseek-v4-pro",
platform: "darwin",
osVersion: "Darwin 25.0.0",
+ shell: "zsh",
date: "2026-06-30",
});
expect(prompt).toBe(
@@ -529,6 +544,7 @@ describe("assembleSystemPrompt", () => {
"pdir=/tmp/proj",
"platform=darwin",
"os=Darwin 25.0.0",
+ "shell=zsh",
"date=2026-06-30",
"middle",
"# Agent Rules\nFollow local rules.",
@@ -572,6 +588,7 @@ describe("assembleSystemPrompt", () => {
modelId: "deepseek-v4-pro",
platform: "darwin",
osVersion: "Darwin 25.0.0",
+ shell: "zsh",
date: "2026-06-30",
});
expect(prompt).toBe("base prompt");
@@ -657,9 +674,11 @@ describe("assembleSystemPrompt", () => {
expect(prompt).toContain("Model ID: gpt-5.5");
expect(prompt).toContain("Platform:");
expect(prompt).toContain("OS Version:");
+ expect(prompt).toContain("Shell:");
expect(prompt).toContain("Date: 2026-06-30");
expect(prompt.indexOf("Platform:")).toBeLessThan(prompt.indexOf("OS Version:"));
- expect(prompt.indexOf("OS Version:")).toBeLessThan(prompt.indexOf("Date:"));
+ expect(prompt.indexOf("OS Version:")).toBeLessThan(prompt.indexOf("Shell:"));
+ expect(prompt.indexOf("Shell:")).toBeLessThan(prompt.indexOf("Date:"));
expect(prompt.indexOf("Date:")).toBeLessThan(prompt.indexOf("App Data Dir:"));
expect(prompt.indexOf("App Data Dir:")).toBeLessThan(prompt.indexOf("Agent ID:"));
expect(prompt.indexOf("Agent ID:")).toBeLessThan(prompt.indexOf("CWD:"));
@@ -667,6 +686,114 @@ describe("assembleSystemPrompt", () => {
expect(prompt.indexOf("Provider:")).toBeLessThan(prompt.indexOf("Model ID:"));
expect(prompt.indexOf("Model ID:")).toBeLessThan(prompt.indexOf("Session ID:"));
});
+
+ // The win32 Shell-line fallback for pre-{{SHELL}} templates (system_config.yaml is baked at
+ // Agent creation and never auto-upgraded). Removable together with `withShellLineFallback`
+ // once pre-{{SHELL}} Agent configs are no longer expected in the wild.
+ describe("Shell line fallback for templates without {{SHELL}}", () => {
+ const stateWithPrompt = (system_prompt: string) => ({
+ root: tmpRoot,
+ projectId: DEFAULT_PROJECT_ID,
+ agentId: DEFAULT_AGENT_ID,
+ stateDir: agentStateDir(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID),
+ systemConfig: { system_prompt },
+ agentsMd: "",
+ });
+ const envFor = (platform: string) => ({
+ sessionId: "session-1",
+ cwd: "C:\\ws",
+ agentId: "agent-x",
+ projectDir: "C:\\proj",
+ provider: "deepseek",
+ modelId: "deepseek-v4-pro",
+ platform,
+ osVersion: "Windows 11 Pro 10.0.26100",
+ shell: "pwsh",
+ date: "2026-07-27",
+ });
+ // A pre-{{SHELL}} default-template Environment section (Platform/OS Version/Date, no Shell).
+ const preShellTemplate = [
+ "intro",
+ "# Environment",
+ `- Platform: ${PLATFORM_PLACEHOLDER}`,
+ `- OS Version: ${OS_VERSION_PLACEHOLDER}`,
+ `- Date: ${DATE_PLACEHOLDER}`,
+ "",
+ "# Tail section",
+ "tail",
+ ].join("\n");
+
+ it("injects the line exactly once into the Environment section on win32", () => {
+ const prompt = assembleSystemPrompt(stateWithPrompt(preShellTemplate), envFor("win32"));
+ expect(prompt).toBe(
+ [
+ "intro",
+ "# Environment",
+ "- Shell: pwsh",
+ "- Platform: win32",
+ "- OS Version: Windows 11 Pro 10.0.26100",
+ "- Date: 2026-07-27",
+ "",
+ "# Tail section",
+ "tail",
+ ].join("\n"),
+ );
+ expect(prompt.split("- Shell: pwsh").length - 1).toBe(1);
+ });
+
+ it("keeps POSIX output byte-identical (no injected line)", () => {
+ for (const platform of ["linux", "darwin"]) {
+ const prompt = assembleSystemPrompt(stateWithPrompt(preShellTemplate), {
+ ...envFor(platform),
+ shell: "bash",
+ osVersion: "Linux 6.1.0",
+ });
+ expect(prompt).toBe(
+ [
+ "intro",
+ "# Environment",
+ `- Platform: ${platform}`,
+ "- OS Version: Linux 6.1.0",
+ "- Date: 2026-07-27",
+ "",
+ "# Tail section",
+ "tail",
+ ].join("\n"),
+ );
+ expect(prompt).not.toContain("Shell:");
+ }
+ });
+
+ it("does not duplicate the line when the template has {{SHELL}}", () => {
+ const template = [
+ "# Environment",
+ `- Platform: ${PLATFORM_PLACEHOLDER}`,
+ `- Shell: ${SHELL_PLACEHOLDER}`,
+ ].join("\n");
+ const prompt = assembleSystemPrompt(stateWithPrompt(template), envFor("win32"));
+ expect(prompt).toBe(["# Environment", "- Platform: win32", "- Shell: pwsh"].join("\n"));
+ expect(prompt.split("- Shell:").length - 1).toBe(1);
+ });
+
+ it("does not duplicate a hardcoded line and appends a minimal line without an Environment section", () => {
+ // A custom template that hardcodes the exact line: left untouched (idempotent).
+ const hardcoded = assembleSystemPrompt(
+ stateWithPrompt("base prompt\n- Shell: pwsh"),
+ envFor("win32"),
+ );
+ expect(hardcoded).toBe("base prompt\n- Shell: pwsh");
+ // A hardcoded line with a different value is a deliberate template choice:
+ // never add a second, contradicting Shell line.
+ const pinned = assembleSystemPrompt(
+ stateWithPrompt("base prompt\n- Shell: bash"),
+ envFor("win32"),
+ );
+ expect(pinned).toBe("base prompt\n- Shell: bash");
+ // No Environment section at all: the line is appended at the end.
+ const appended = assembleSystemPrompt(stateWithPrompt("base prompt"), envFor("win32"));
+ expect(appended).toBe("base prompt\n- Shell: pwsh");
+ });
+ });
});
describe("resetSystemConfigToDefaults", () => {
@@ -1174,28 +1301,35 @@ describe("single hidden config file (.project_config.toml, credentials inlined)"
// The sole config file is hidden (not shown by ls by default) and has 0600 permission
// (owner read/write only).
expect(path.basename(file)).toBe(".project_config.toml");
- expect((await fs.stat(file)).mode & 0o777).toBe(0o600);
+ // POSIX-only: Windows has no owner-only mode bits (chmod maps to the read-only attribute).
+ if (process.platform !== "win32") {
+ expect((await fs.stat(file)).mode & 0o777).toBe(0o600);
+ }
expect(await fs.readFile(file, "utf8")).toContain("sk-split-1");
// The old two-file layout is no longer produced.
expect(await exists(path.join(tmpRoot, DEFAULT_PROJECT_ID, "project_config.toml"))).toBe(false);
expect(await exists(path.join(tmpRoot, DEFAULT_PROJECT_ID, ".credentials.toml"))).toBe(false);
});
- it("chmod converges an existing file back to 0600 on save", async () => {
- await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
- provider: "custom",
- model_id: "m-perm",
- api_key: "sk-1",
- });
- const file = projectConfigPath(tmpRoot, DEFAULT_PROJECT_ID);
- await fs.chmod(file, 0o644);
- await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
- provider: "custom",
- model_id: "m-perm",
- api_key: "sk-2",
- });
- expect((await fs.stat(file)).mode & 0o777).toBe(0o600);
- });
+ // POSIX-only: Windows has no owner-only mode bits to converge.
+ it.skipIf(process.platform === "win32")(
+ "chmod converges an existing file back to 0600 on save",
+ async () => {
+ await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
+ provider: "custom",
+ model_id: "m-perm",
+ api_key: "sk-1",
+ });
+ const file = projectConfigPath(tmpRoot, DEFAULT_PROJECT_ID);
+ await fs.chmod(file, 0o644);
+ await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
+ provider: "custom",
+ model_id: "m-perm",
+ api_key: "sk-2",
+ });
+ expect((await fs.stat(file)).mode & 0o777).toBe(0o600);
+ },
+ );
it("writes provider and model_id as separate fields; refs are TOML inline tables", async () => {
await addModel(
@@ -1238,7 +1372,10 @@ describe("agent vault (agent_state/.vault.toml)", () => {
// with 0600 permission (owner read/write only).
const file = agentVaultPath(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID);
expect(path.basename(file)).toBe(".vault.toml");
- expect((await fs.stat(file)).mode & 0o777).toBe(0o600);
+ // POSIX-only: Windows has no owner-only mode bits (chmod maps to the read-only attribute).
+ if (process.platform !== "win32") {
+ expect((await fs.stat(file)).mode & 0o777).toBe(0o600);
+ }
const raw = await fs.readFile(file, "utf8");
expect(raw).toContain("sk-secret-2");
// The Project config no longer carries the vault.
diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts
new file mode 100644
index 0000000..3638d30
--- /dev/null
+++ b/packages/core/vitest.config.ts
@@ -0,0 +1,16 @@
+/**
+ * Vitest config for core. The one non-default setting is a platform-aware test timeout:
+ * many core tests spawn a real shell (Git-Bash on the Windows CI runners) or write the
+ * full Agent State layout, and Windows runner I/O variance (cold shell spawns, Defender
+ * first-touch scans, slow-disk moments) has pushed individually fast tests past vitest's
+ * 5s default — a different test on each run. A larger failure deadline changes nothing
+ * for passing tests; POSIX keeps the 5s default to fail fast during local development.
+ */
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "node",
+ testTimeout: process.platform === "win32" ? 30_000 : 5_000,
+ },
+});
diff --git a/packages/docs/content/installation.en.md b/packages/docs/content/installation.en.md
index 218e12f..371b457 100644
--- a/packages/docs/content/installation.en.md
+++ b/packages/docs/content/installation.en.md
@@ -6,6 +6,7 @@ description: Install PenguinHarness via the install script, npm, or from source.
## Requirements
- Linux / macOS (x64 or arm64): the install script ships platform tarballs with an official Node.js runtime bundled — no local Node needed.
+- Windows 10 or later (x64) with PowerShell 5.1+: the Windows installer ships `penguin-win32-x64.zip` with the runtime bundled — no local Node needed.
- Other platforms, or installing via npm / from source: system Node.js >= 24.
## Script install (recommended)
@@ -16,7 +17,19 @@ On Linux / macOS:
curl -fsSL https://penguin.ooo/install.sh | sh
```
-The script downloads the matching `penguin-{linux,darwin}-{x64,arm64}.tar.gz`, which bundles an official Node.js runtime. Other platforms do **not** fall back automatically: the script exits and asks you to install Node.js >= 24 and re-run with `--universal`, which selects the runtime-less `penguin-universal.tar.gz`.
+The script downloads the matching `penguin-{linux,darwin}-{x64,arm64}.tar.gz`, which bundles an official Node.js runtime. Other POSIX platforms do **not** fall back automatically: the script exits and asks you to install Node.js >= 24 and re-run with `--universal`, which selects the runtime-less `penguin-universal.tar.gz` (Windows is served by its own installer below, not by `--universal`).
+
+On Windows (PowerShell):
+
+```powershell
+irm https://penguin.ooo/install.ps1 | iex
+```
+
+To pin a version, set the env var first:
+
+```powershell
+$env:PENGUIN_VERSION = "vX.Y.Z"; irm https://penguin.ooo/install.ps1 | iex
+```
Verify the install:
@@ -36,9 +49,25 @@ penguin -v
Script flags go after `sh -s --`, e.g. `curl -fsSL https://penguin.ooo/install.sh | sh -s -- --universal`.
+### Windows specifics
+
+| Item | Details |
+| --- | --- |
+| Install dir | `%USERPROFILE%\.penguin` by default; override with the `PENGUIN_INSTALL_DIR` env var |
+| Command entry | `bin\penguin.cmd` and `bin\penguin.ps1` launchers; the installer adds `%USERPROFILE%\.penguin\bin` to your **user** Path (restart the terminal once) |
+| Version pin | `$env:PENGUIN_VERSION = "vX.Y.Z"` before running the installer |
+| Integrity check | Downloads are sha256-verified when the Release ships checksum assets |
+| Upgrade | Re-run the installer; it swaps `bin`/`lib`/`web`/`node` and never touches `data` |
+
+- **Agent shell**: on Windows, the agent's `exec_command` prefers Git-Bash (`bash` on PATH, e.g. from [Git for Windows](https://gitforwindows.org/)) for the best compatibility with skills written for a POSIX shell, and falls back to PowerShell (`pwsh`, then `powershell`) when bash is absent. The `PENGUIN_SHELL` env var overrides the pick; the session's system prompt tells the model which shell is active.
+- **Ctrl-C semantics**: on Windows, sending Ctrl-C to a running command session (`input_command` with `"\u0003"`) terminates the whole command session tree instead of interrupting the foreground command — Windows cannot deliver a console Ctrl-C to a piped child process, so the interrupt degrades to a hard tree kill.
+- **In-place update**: `penguin update` is not yet supported on Windows — upgrade by re-running the installer above.
+- **Config file permissions**: on POSIX, config/credential files are written with `0600` (owner-only) permissions; Windows has no such mode bits, so files fall under your profile's default NTFS ACLs.
+- If PowerShell refuses to run `penguin` with "running scripts is disabled", your execution policy blocks the `penguin.ps1` shim: either call `penguin.cmd` explicitly, or allow local scripts with `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`.
+
### Data directory
-The data directory defaults to `~/.penguin/data` — under the install home `~/.penguin`, but never modified by install or upgrade — and is overridable with the `PENGUIN_HOME` env var. Model configuration, Session records, and other data are preserved across upgrades.
+The data directory defaults to `~/.penguin/data` (`%USERPROFILE%\.penguin\data` on Windows) — under the install home, but never modified by install or upgrade — and is overridable with the `PENGUIN_HOME` env var. Model configuration, Session records, and other data are preserved across upgrades.
## npm install
@@ -48,7 +77,7 @@ Requires system Node.js >= 24:
npm install -g @prismshadow/penguin-cli
```
-The npm package is `@prismshadow/penguin-cli`; the installed command is `penguin`. Web UI assets ship inside the `@prismshadow/penguin-server` package, so this single install yields a working `penguin web`.
+The npm package is `@prismshadow/penguin-cli`; the installed command is `penguin`. Web UI assets ship inside the `@prismshadow/penguin-server` package, so this single install yields a working `penguin web`. This route works on every platform (including Windows) and is the alternative when the packaged zip/tarball is unsuitable.
## From source
diff --git a/packages/docs/content/installation.zh.md b/packages/docs/content/installation.zh.md
index 51d51d7..502c5de 100644
--- a/packages/docs/content/installation.zh.md
+++ b/packages/docs/content/installation.zh.md
@@ -6,6 +6,7 @@ description: 通过安装脚本、npm 或源码安装 PenguinHarness。
## 系统要求
- Linux / macOS(x64 或 arm64):安装脚本提供内置官方 Node.js 运行时的平台压缩包,解压即用,无需本机安装 Node。
+- Windows 10 及以上(x64),PowerShell 5.1+:Windows 安装器提供内置运行时的 `penguin-win32-x64.zip`,同样无需本机安装 Node。
- 其他平台,或通过 npm / 源码安装:需要系统 Node.js >= 24。
## 脚本安装(推荐)
@@ -16,7 +17,19 @@ description: 通过安装脚本、npm 或源码安装 PenguinHarness。
curl -fsSL https://penguin.ooo/install.sh | sh
```
-脚本按平台下载 `penguin-{linux,darwin}-{x64,arm64}.tar.gz`,其中捆绑了官方 Node.js 运行时。其他平台**不会自动回退**:脚本会退出并提示先安装 Node.js >= 24、再携带 `--universal` 重新执行,改用不含运行时的 `penguin-universal.tar.gz`。
+脚本按平台下载 `penguin-{linux,darwin}-{x64,arm64}.tar.gz`,其中捆绑了官方 Node.js 运行时。其他 POSIX 平台**不会自动回退**:脚本会退出并提示先安装 Node.js >= 24、再携带 `--universal` 重新执行,改用不含运行时的 `penguin-universal.tar.gz`(Windows 使用下方专属安装器,而不是 `--universal`)。
+
+在 Windows(PowerShell)上执行:
+
+```powershell
+irm https://penguin.ooo/install.ps1 | iex
+```
+
+如需固定版本,先设置环境变量:
+
+```powershell
+$env:PENGUIN_VERSION = "vX.Y.Z"; irm https://penguin.ooo/install.ps1 | iex
+```
安装完成后验证:
@@ -36,9 +49,25 @@ penguin -v
脚本参数写在 `sh -s --` 之后,例如 `curl -fsSL https://penguin.ooo/install.sh | sh -s -- --universal`。
+### Windows 细节
+
+| 项目 | 说明 |
+| --- | --- |
+| 安装目录 | 默认 `%USERPROFILE%\.penguin`,可用环境变量 `PENGUIN_INSTALL_DIR` 覆盖 |
+| 命令入口 | `bin\penguin.cmd` 与 `bin\penguin.ps1` 启动器;安装器会把 `%USERPROFILE%\.penguin\bin` 加入**用户** Path(重启终端后生效) |
+| 版本固定 | 运行安装器前设置 `$env:PENGUIN_VERSION = "vX.Y.Z"` |
+| 完整性校验 | Release 提供 checksum 资产时自动进行 sha256 校验 |
+| 升级 | 重新运行安装器;只替换 `bin`/`lib`/`web`/`node`,绝不触碰 `data` |
+
+- **Agent shell**:Windows 上 `exec_command` 优先使用 Git-Bash(PATH 上的 `bash`,如 [Git for Windows](https://gitforwindows.org/)),与面向 POSIX shell 编写的技能生态兼容性最好;没有 bash 时回退到 PowerShell(先 `pwsh` 后 `powershell`)。环境变量 `PENGUIN_SHELL` 可强制指定;会话的系统提示词会告知模型当前 shell。
+- **Ctrl-C 语义**:Windows 上向运行中的命令会话发送 Ctrl-C(`input_command` 传 `"\u0003"`)会终止整棵命令会话进程树,而不是中断前台命令——Windows 无法向管道子进程投递控制台 Ctrl-C,中断因此退化为整树强杀。
+- **就地更新**:`penguin update` 暂不支持 Windows——升级请重新运行上面的安装器。
+- **配置文件权限**:POSIX 上配置/凭据文件以 `0600`(仅属主可读写)写入;Windows 没有对应的权限位,文件遵循你用户目录的默认 NTFS ACL。
+- 如果 PowerShell 提示 "running scripts is disabled" 而无法运行 `penguin`,是执行策略拦住了 `penguin.ps1`:可以显式调用 `penguin.cmd`,或用 `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` 允许本地脚本。
+
### 数据目录
-数据目录默认位于 `~/.penguin/data`(在安装主目录 `~/.penguin` 之下,但安装与升级都不会改动它),可用环境变量 `PENGUIN_HOME` 覆盖。模型配置、Session 记录等在升级后均会保留。
+数据目录默认位于 `~/.penguin/data`(Windows 为 `%USERPROFILE%\.penguin\data`),在安装主目录之下,但安装与升级都不会改动它,可用环境变量 `PENGUIN_HOME` 覆盖。模型配置、Session 记录等在升级后均会保留。
## npm 安装
@@ -48,7 +77,7 @@ penguin -v
npm install -g @prismshadow/penguin-cli
```
-npm 包名为 `@prismshadow/penguin-cli`,安装后的命令是 `penguin`。Web UI 静态资源随 `@prismshadow/penguin-server` 包发布,因此仅执行上述命令即可直接使用 `penguin web`。
+npm 包名为 `@prismshadow/penguin-cli`,安装后的命令是 `penguin`。Web UI 静态资源随 `@prismshadow/penguin-server` 包发布,因此仅执行上述命令即可直接使用 `penguin web`。该方式在所有平台(含 Windows)可用,是压缩包不适用时的替代路径。
## 源码安装
diff --git a/packages/docs/content/tools.en.md b/packages/docs/content/tools.en.md
index e928491..8d4d397 100644
--- a/packages/docs/content/tools.en.md
+++ b/packages/docs/content/tools.en.md
@@ -116,6 +116,8 @@ Both tools' arguments (explicit keys):
}
```
+On POSIX, Ctrl-C sends `SIGINT` to the session's process group, interrupting the foreground command. On Windows there is no console signal delivery to a piped child process, so Ctrl-C degrades to a hard kill of the whole command session tree (`taskkill /t /f`) — the foreground command and every child it started terminate, instead of the foreground command being interrupted.
+
### File tools
`read_file` / `edit_file` / `write_file` run with the user's full permissions, same as the shell tool; relative paths resolve against the Workspace and absolute paths are allowed. They are non-streaming (a single final output) and never throw — failures come back as explanatory text with `stop_reason: failed`.
diff --git a/packages/docs/content/tools.zh.md b/packages/docs/content/tools.zh.md
index e0ce45c..0600934 100644
--- a/packages/docs/content/tools.zh.md
+++ b/packages/docs/content/tools.zh.md
@@ -115,6 +115,8 @@ exec_command(cmd)
}
```
+POSIX 上 Ctrl-C 向会话进程组发送 `SIGINT`,中断前台命令。Windows 无法向管道子进程投递控制台信号,Ctrl-C 因此退化为整棵命令会话进程树的强杀(`taskkill /t /f`)——前台命令及其启动的所有子进程一并终止,而不是仅中断前台命令。
+
### 文件工具
`read_file` / `edit_file` / `write_file` 与 Shell 工具一样以用户完整权限运行:相对路径按 Workspace 解析,也接受绝对路径。三者均为非流式(一次性输出最终结果),从不抛异常——失败以解释性文本收尾,`stop_reason` 为 `failed`。
diff --git a/packages/landing/public/install.ps1 b/packages/landing/public/install.ps1
new file mode 100644
index 0000000..d9eed1e
--- /dev/null
+++ b/packages/landing/public/install.ps1
@@ -0,0 +1,33 @@
+# https://penguin.ooo/install.ps1 - PenguinHarness installer entry point for Windows.
+#
+# GitHub Pages cannot serve HTTP redirects, so this thin forwarder IS the
+# stable install URL: it fetches the real installer attached to the latest
+# GitHub release and runs it, forwarding every argument it was given. Usage:
+#
+# irm https://penguin.ooo/install.ps1 | iex
+# & ([scriptblock]::Create((irm https://penguin.ooo/install.ps1))) -Version v0.2.0
+#
+$ErrorActionPreference = "Stop"
+$ProgressPreference = "SilentlyContinue"
+try {
+ [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
+} catch {
+ # .NET builds where the enum is immutable already default to TLS 1.2+.
+}
+# Download fully first, then run: executing a piped stream directly would run a
+# truncated download line by line, and the real installer moves the old
+# bin/lib/web/node aside before moving the new ones in - a cut connection
+# mid-way must never leave a half-executed installer. The installer runs as an
+# in-memory script block (not a script file): script files are subject to the
+# execution policy, which is Restricted by default on client Windows - while the
+# user has already consented to remote code by piping this forwarder into iex.
+# Neither this forwarder nor the installer calls `exit`, which in iex/script-block
+# context would terminate the user's whole PowerShell session.
+$Tmp = Join-Path ([IO.Path]::GetTempPath()) "penguin-install-$PID.ps1"
+try {
+ Invoke-WebRequest -Uri "https://github.com/Prism-Shadow/penguin-harness/releases/latest/download/install.ps1" -OutFile $Tmp -UseBasicParsing
+ $Installer = [scriptblock]::Create((Get-Content -Path $Tmp -Raw))
+ & $Installer @args
+} finally {
+ Remove-Item -Force $Tmp -ErrorAction SilentlyContinue
+}
diff --git a/packages/landing/src/lib/links.ts b/packages/landing/src/lib/links.ts
index f704214..2692ef9 100644
--- a/packages/landing/src/lib/links.ts
+++ b/packages/landing/src/lib/links.ts
@@ -18,6 +18,12 @@ export const DOCS_URL = `${import.meta.env.BASE_URL}docs/`;
*/
export const INSTALL_CMD = "curl -fsSL https://penguin.ooo/install.sh | sh";
+/**
+ * One-line installer for Windows (PowerShell 5.1+, x64, bundled Node runtime).
+ * penguin.ooo/install.ps1 is public/install.ps1 — the same thin-forwarder design.
+ */
+export const INSTALL_CMD_WINDOWS = "irm https://penguin.ooo/install.ps1 | iex";
+
/**
* Demo videos live in the sibling `penguin-harness-community` repo rather than in this
* one: they are ~9 MB each, and this repo's whole history is ~17 MB, so committing them
diff --git a/packages/landing/src/lib/strings-en.ts b/packages/landing/src/lib/strings-en.ts
index ba9231d..9c03342 100644
--- a/packages/landing/src/lib/strings-en.ts
+++ b/packages/landing/src/lib/strings-en.ts
@@ -59,7 +59,9 @@ export const en: Strings = {
ctaPrimary: "Get started",
ctaGithub: "GitHub",
installHint:
- "One-line install (Linux / macOS, x64 / arm64, bundled Node runtime — unpack and run)",
+ "One-line install (Linux / macOS / Windows, bundled Node runtime — unpack and run)",
+ installLabelPosix: "Linux / macOS",
+ installLabelWindows: "Windows",
stats: [
{ value: "1000+", label: "supported models" },
{ value: "1×CPU", label: "minimum footprint" },
@@ -155,7 +157,9 @@ export const en: Strings = {
"Install with one command and let the Agent work from a desktop-grade interface — all data stays in your local ~/.penguin/data directory.",
step1: "Install",
step1Desc:
- "Linux / macOS (x64 / arm64) with a bundled Node runtime — unpack and run; upgrades never touch your data.",
+ "Linux / macOS / Windows with a bundled Node runtime — unpack and run; upgrades never touch your data.",
+ installLabelPosix: "Linux / macOS",
+ installLabelWindows: "Windows (PowerShell)",
tabWeb: "Web UI",
tabCli: "CLI",
webStep2: "Open the web interface",
diff --git a/packages/landing/src/lib/strings.ts b/packages/landing/src/lib/strings.ts
index 4b21440..2bb27cc 100644
--- a/packages/landing/src/lib/strings.ts
+++ b/packages/landing/src/lib/strings.ts
@@ -63,7 +63,9 @@ export const zh = {
keywords: ["轻量", "高效", "开源"],
ctaPrimary: "快速开始",
ctaGithub: "GitHub",
- installHint: "一行命令安装(Linux / macOS,x64 / arm64,内嵌 Node 运行时,解压即用)",
+ installHint: "一行命令安装(Linux / macOS / Windows,内嵌 Node 运行时,解压即用)",
+ installLabelPosix: "Linux / macOS",
+ installLabelWindows: "Windows",
stats: [
{ value: "1000+", label: "支持模型数量" },
{ value: "1×CPU", label: "最低运行配置" },
@@ -156,8 +158,9 @@ export const zh = {
subtitle:
"一行命令安装,打开桌面级界面即可让 Agent 开始工作;数据全部保存在本地 ~/.penguin/data 目录。",
step1: "安装",
- step1Desc:
- "Linux / macOS(x64 / arm64),产物内嵌 Node 运行时,解压即用;升级与重装不触碰数据。",
+ step1Desc: "Linux / macOS / Windows,产物内嵌 Node 运行时,解压即用;升级与重装不触碰数据。",
+ installLabelPosix: "Linux / macOS",
+ installLabelWindows: "Windows(PowerShell)",
tabWeb: "Web 界面",
tabCli: "命令行",
webStep2: "启动 Web 界面",
diff --git a/packages/landing/src/sections/hero.tsx b/packages/landing/src/sections/hero.tsx
index 9a3803e..cf2b911 100644
--- a/packages/landing/src/sections/hero.tsx
+++ b/packages/landing/src/sections/hero.tsx
@@ -7,7 +7,7 @@
import { Fragment, useEffect, useState } from "react";
import { Link } from "react-router";
import { S } from "../lib/strings";
-import { INSTALL_CMD, REPO_URL } from "../lib/links";
+import { INSTALL_CMD, INSTALL_CMD_WINDOWS, REPO_URL } from "../lib/links";
import { CopyButton } from "../components/copy-button";
import { ArrowRightIcon, GitHubIcon } from "../components/icons";
@@ -115,12 +115,28 @@ export function Hero() {
className="anim-rise mx-auto mt-10 w-fit max-w-full"
style={{ animationDelay: "240ms" }}
>
-
-
- $
- {INSTALL_CMD}
-
-
+ {/* One install row per OS (labels are language-neutral; prompt chars $ vs > match the shells). */}
+
+
+
+ {S.hero.installLabelPosix}
+
+
+ $
+ {INSTALL_CMD}
+
+
+
+
+
+ {S.hero.installLabelWindows}
+
+
+ >
+ {INSTALL_CMD_WINDOWS}
+
+
+
{S.hero.installHint}
diff --git a/packages/landing/src/sections/quickstart.tsx b/packages/landing/src/sections/quickstart.tsx
index 5bc257c..cf291e0 100644
--- a/packages/landing/src/sections/quickstart.tsx
+++ b/packages/landing/src/sections/quickstart.tsx
@@ -7,7 +7,12 @@
import { useState } from "react";
import type { ReactNode } from "react";
import { S } from "../lib/strings";
-import { DEEPSEEK_KEYS_URL, INSTALL_CMD, OPENROUTER_KEYS_URL } from "../lib/links";
+import {
+ DEEPSEEK_KEYS_URL,
+ INSTALL_CMD,
+ INSTALL_CMD_WINDOWS,
+ OPENROUTER_KEYS_URL,
+} from "../lib/links";
import { Section } from "../components/section";
import { CodeCard } from "../components/code-card";
import {
@@ -125,7 +130,12 @@ export function Quickstart() {
title={S.quickstart.step1}
desc={S.quickstart.step1Desc}
>
-
+
+
{mode === "web" ? (
diff --git a/packages/server/package.json b/packages/server/package.json
index 7865ac8..b852a4b 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -25,7 +25,7 @@
"node": ">=24"
},
"scripts": {
- "dev": "node ../../scripts/dev-prebuild.mjs && PENGUIN_HOME=\"${PENGUIN_HOME:-$HOME/.penguin/dev-data}\" tsx watch src/index.ts",
+ "dev": "node ../../scripts/dev-prebuild.mjs && node ../../scripts/run-with-env.mjs PENGUIN_HOME=~/.penguin/dev-data -- tsx watch src/index.ts",
"start": "node --disable-warning=ExperimentalWarning dist/index.js",
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "vitest run --passWithNoTests",
diff --git a/packages/server/src/services/workspace-files-service.ts b/packages/server/src/services/workspace-files-service.ts
index 139fecb..e70d25d 100644
--- a/packages/server/src/services/workspace-files-service.ts
+++ b/packages/server/src/services/workspace-files-service.ts
@@ -262,6 +262,15 @@ export class WorkspaceFilesService {
}
const { dir, name } = await this.resolveWriteParent(workspace, rel);
const file = path.join(dir, name);
+ // Windows has no O_NOFOLLOW (the `?? 0` below erases it), so the atomic ELOOP guard never
+ // fires there — refuse a final-segment symlink via lstat instead. Best effort (a link
+ // created between this check and the open wins the race), but it closes the practical
+ // "preset a symlink, overwrite an outside file by upload" escape; POSIX keeps the atomic
+ // open-time guarantee.
+ if (process.platform === "win32") {
+ const st = await fs.lstat(file).catch(() => null);
+ if (st?.isSymbolicLink()) throw badRequest("path must not be a symlink.");
+ }
// O_NOFOLLOW: open reports ELOOP if the final segment is a symlink, refusing to use it as leverage to overwrite a file outside the sandbox.
const flags = fsc.O_WRONLY | fsc.O_CREAT | fsc.O_TRUNC | (fsc.O_NOFOLLOW ?? 0);
let handle;
diff --git a/packages/server/test/models.test.ts b/packages/server/test/models.test.ts
index 9340c20..f19e8cf 100644
--- a/packages/server/test/models.test.ts
+++ b/packages/server/test/models.test.ts
@@ -82,7 +82,10 @@ describe("models preset & catalog enrichment", () => {
expect(cfgRaw).toContain('provider = "custom"');
expect(cfgRaw).toContain('model_id = "m-inline"');
expect(cfgRaw).not.toContain("custom/m-inline");
- expect((await stat(cfgFile)).mode & 0o777).toBe(0o600);
+ // POSIX-only: Windows has no owner-only mode bits (chmod maps to the read-only attribute).
+ if (process.platform !== "win32") {
+ expect((await stat(cfgFile)).mode & 0o777).toBe(0o600);
+ }
// No more separate .credentials.toml / project_config.toml files.
await expect(readFile(path.join(projectDir, ".credentials.toml"), "utf8")).rejects.toThrow();
await expect(readFile(path.join(projectDir, "project_config.toml"), "utf8")).rejects.toThrow();
diff --git a/packages/web/e2e/README.md b/packages/web/e2e/README.md
index 1b28521..1d403bc 100644
--- a/packages/web/e2e/README.md
+++ b/packages/web/e2e/README.md
@@ -12,3 +12,8 @@ SKIP_BUILD=1 pnpm --filter @prismshadow/penguin-web test:e2e # skip the build
```
The first run requires `npx playwright install chromium`.
+
+The runner (`run.sh`) is POSIX-only and deliberately stays that way: `pnpm test` never
+invokes it (only the separate `test:e2e` script does), so a Windows checkout builds and
+tests fine without it. To run the browser e2e on Windows, use Git-Bash
+(`bash e2e/run.sh` from `packages/web`).
diff --git a/scripts/dev-prebuild.mjs b/scripts/dev-prebuild.mjs
index 42894fe..29bb484 100644
--- a/scripts/dev-prebuild.mjs
+++ b/scripts/dev-prebuild.mjs
@@ -110,7 +110,12 @@ function ensureInstalled() {
return true;
}
console.log("[dev-prebuild] dependencies missing or lockfile changed; running pnpm install...");
- const res = spawnSync("pnpm", ["install"], { cwd: ROOT, stdio: "inherit" });
+ // shell on Windows: pnpm is a .cmd shim there, which Node refuses to spawn shell-less (CVE-2024-27980).
+ const res = spawnSync("pnpm", ["install"], {
+ cwd: ROOT,
+ stdio: "inherit",
+ shell: process.platform === "win32",
+ });
if (res.status !== 0) return false;
writeFileSync(INSTALL_STAMP, hash);
return true;
@@ -156,7 +161,8 @@ try {
"@prismshadow/penguin-core",
"build",
],
- { cwd: ROOT, stdio: "inherit" },
+ // shell on Windows: pnpm is a .cmd shim there (see ensureInstalled).
+ { cwd: ROOT, stdio: "inherit", shell: process.platform === "win32" },
);
if (res.status === 0) writeFileSync(BUILD_STAMP, String(Date.now()));
exitCode = res.status ?? 1;
diff --git a/scripts/run-with-env.mjs b/scripts/run-with-env.mjs
new file mode 100644
index 0000000..eeefb69
--- /dev/null
+++ b/scripts/run-with-env.mjs
@@ -0,0 +1,68 @@
+#!/usr/bin/env node
+/**
+ * Cross-platform replacement for POSIX env-prefix script lines.
+ *
+ * node scripts/run-with-env.mjs VAR=value [VAR2=value2 ...] -- [args...]
+ *
+ * Each assignment sets VAR **only when it is unset or empty** in the current environment —
+ * the JS equivalent of `VAR="${VAR:-value}" cmd`, which cmd.exe cannot parse (package.json
+ * scripts run under cmd.exe on Windows). A leading `~/` in the value expands to the user's
+ * home directory (the `$HOME/...` defaults). Then the command runs with inherited stdio and
+ * its exit code is propagated.
+ *
+ * On Windows the command is a pnpm-installed shim (tsx.cmd, vitest.cmd), which Node refuses
+ * to spawn without a shell since CVE-2024-27980; those runs go through the shell with
+ * quoting applied. POSIX keeps a plain shell-less spawn (bit-for-bit today's behavior).
+ */
+import os from "node:os";
+import path from "node:path";
+import { spawn } from "node:child_process";
+
+const argv = process.argv.slice(2);
+const sep = argv.indexOf("--");
+if (sep === -1 || sep === argv.length - 1) {
+ console.error("usage: node scripts/run-with-env.mjs VAR=value [...] -- [args...]");
+ process.exit(2);
+}
+
+const env = { ...process.env };
+for (const assignment of argv.slice(0, sep)) {
+ const eq = assignment.indexOf("=");
+ if (eq <= 0) {
+ console.error(`run-with-env: not an assignment: ${assignment}`);
+ process.exit(2);
+ }
+ const name = assignment.slice(0, eq);
+ let value = assignment.slice(eq + 1);
+ if (value === "~" || value.startsWith("~/")) {
+ value = path.join(os.homedir(), value.slice(2));
+ }
+ if (!env[name]) env[name] = value; // unset or empty -> default (the ${VAR:-value} rule)
+}
+
+const [command, ...args] = argv.slice(sep + 1);
+const windows = process.platform === "win32";
+// cmd.exe does not unquote spawn args by itself; quote anything with spaces or cmd
+// metacharacters (best effort — cmd has no lossless general quoting, but dev-script args
+// are file paths and short flags).
+const quote = (a) => (/[\s"^&|<>;,()%!]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a);
+const child = windows
+ ? spawn([command, ...args].map(quote).join(" "), {
+ stdio: "inherit",
+ env,
+ shell: true,
+ windowsHide: true,
+ })
+ : spawn(command, args, { stdio: "inherit", env });
+
+child.on("error", (err) => {
+ console.error(`run-with-env: failed to start ${command}: ${err.message}`);
+ process.exit(1);
+});
+child.on("exit", (code, signal) => {
+ if (signal) {
+ // Mirror the shell convention for signal deaths so callers see a failure.
+ process.exit(1);
+ }
+ process.exit(code ?? 1);
+});