# Windows: a real install story — shell selection, install.ps1, and a win-x64 release package

The audit found the dependency graph already clean for Windows (`node:sqlite`, pure-JS `tar`, no native modules) and `npm install -g @prismshadow/penguin-cli` already yields a working `penguin` — but every `exec_command` died with `spawn bash ENOENT`, because the command session hardcoded `spawn("bash", ["-lc", cmd])`. That one line was the product-level blocker; the rest was packaging and honesty.

## The agent actually works: shell selection

Command sessions now resolve their shell once per process: POSIX stays `bash -lc` bit for bit; on Windows the resolver probes PATH for Git-Bash first (best compatibility with the POSIX-oriented skill ecosystem; a `bash` that resolves into the Windows system directory is rejected — that's the WSL launcher, a different filesystem view entirely), then `pwsh`, then `powershell` (`-NoLogo -NoProfile -Command`). `PENGUIN_SHELL` overrides everywhere, with the argument shape inferred from the basename. The chosen shell is announced to the model through a new `Shell:` line in the session environment, so it writes commands in the syntax it actually has. Existing Agents whose `system_config.yaml` predates the `{{SHELL}}` placeholder get the same line through a narrow assembly-time fallback (win32 only, in-memory, no migration; see the backward-compatibility entry) — without it their models would keep emitting bash into PowerShell forever. Termination follows the platform: POSIX keeps process-group signal escalation; Windows kills the whole tree via `taskkill /pid <pid> /t /f` (no console signal delivery to piped children, so `input_command`'s Ctrl-C degrades to a hard kill — documented rather than pretended away).

## A sandbox gap the new CI caught

Windows has no `O_NOFOLLOW`, and the workspace upload path's `(O_NOFOLLOW ?? 0)` silently erased the final-segment symlink guard there — a practical "preset a symlink, overwrite a file outside the Workspace by upload" escape. Uploads on win32 now refuse a final-segment symlink via `lstat` (best-effort against the race; POSIX keeps the atomic open-time guarantee).

## Install and release

`install.ps1` at the repo root mirrors `install.sh`: `PENGUIN_VERSION` / `PENGUIN_INSTALL_DIR` knobs, SHA256 verification, a staged rename-then-delete swap that never touches `data\`, generated `penguin.cmd` / `penguin.ps1` shims (both CRLF), and a user-`Path` update that reads and writes the registry value raw with its kind preserved — the naive `[Environment]` API expands `REG_EXPAND_SZ` on read and writes back `REG_SZ`, irreversibly hard-coding a user's `%USERPROFILE%`-style entries. The stable URL `https://penguin.ooo/install.ps1` is a forwarder that downloads fully before executing — a truncated stream can't half-install. The release workflow gains `penguin-win32-x64.zip` (official Windows Node bundled, `node.exe` at the archive root, CRLF launchers) plus its checksum, and uploads `install.ps1` alongside `install.sh`. Upgrading is re-running the installer; in-place `penguin update` still refuses on Windows, and the docs say so. The one-liner is `irm https://penguin.ooo/install.ps1 | iex`, with `npm install -g` (Node ≥ 24) as the script-free alternative; the landing page shows both install rows and the README roadmap checks off Windows support.

## Keeping it true: CI and the long tail

A `ci-windows` job (windows-latest, full build/typecheck/test plus a PowerShell parse gate) now runs beside the required Ubuntu job; getting it green surfaced four genuine Windows findings (file-lock `EBUSY` on cleanup, a wrong timeout-test premise, the symlink gap above, path-separator test assumptions), all fixed. `.gitattributes` pins LF (with CRLF for `.ps1`) so a Windows checkout can't fail the Prettier gate or corrupt shell scripts. Cross-platform env handling replaces the POSIX-only `VAR=x` npm-script prefixes with a small runner script, `penguin config lang` refuses cleanly on win32 (pointing at `setx PENGUIN_LANG`), and the installer was functionally exercised with real PowerShell against a local fake release: fresh install, upgrade preserving `data\`, checksum-mismatch abort, and the full `irm | iex` chain. Known and documented limits: no 0600 semantics for config/vault files (NTFS ACLs apply), x64 package only (ARM64 runs via emulation), execution-policy notes for the `.ps1` shim, no SIGTERM-driven graceful shutdown, and — now stated in the install and tools docs, not just in source — Ctrl-C in `input_command` kills the whole command-session tree on Windows instead of interrupting the foreground command. Keeping the Windows CI honest also meant giving win32 test runs a longer vitest timeout and platform-gating one spawn-timing assertion; each flake it caught was a real timing sensitivity, not a product bug.
