# dsh-win-pwsh7

简体中文 · English

Puts a real **PowerShell 7** under the Harness `pwsh` tool on Windows, instead of silently falling back to Windows PowerShell 5.1. Three legs, in priority order: 1. **Detect** the PowerShell 7 install on this machine (including an `overridePath` you set), 2. **Validate** any path you hand it (`pwsh7_status` takes an optional `path` argument), 3. **Install** PowerShell 7 from the Microsoft Store when you ask for it (`pwsh7_install`, or `installIfMissing: true`) — self-updating, with the connection checked first, because Windows defers Store downloads on a metered one. The other answer is to disable this plugin. **It does not ship PowerShell, and it does not write configuration.** Installing is always an explicit act (a tool you ask for, or a config key you set); it never uninstalls anything. ## Installing this plugin ```powershell # From GitHub (recommended: pinned to the tag, so a later push cannot change what is installed) dsh plugin --profile desktop add github:XinSpark/dsh-win-pwsh7#v0.12.1 # replace `desktop` with the profile you use — `dsh plugin list` says which one that is ``` - **npm**: `dsh plugin --profile desktop add dsh-win-pwsh7` (or `npm i dsh-win-pwsh7` directly) - **Local development**: `npm pack`, then `dsh plugin --profile desktop add file:` Restart DSH afterwards, then check the runtime it reads on the plugin page (or by asking for `pwsh7_status`). To wire the executor, paste the profile patch the report prints into `~\.dsh\profiles\\cordis.patch.yml` and restart. **It never edits the executor's `pwshPath` for you** — that is deliberate (see below). This plugin is **pure JavaScript with no runtime dependencies**, for Windows: the host half only does meaningful work there, and the client half is an ordinary web card. ## Why it is needed The Desktop adapter resolves the PowerShell executable from well-known candidates plus `PATH` entries: 1. `C:\Program Files\PowerShell\7\pwsh.exe` 2. every `PATH` entry (this is where a **Microsoft Store** install is found — through its execution alias) 3. `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe` (5.1) On 5.1, `&&` and `||` are **parse errors**, so every command the model writes with `&&` fails before it runs. ## Installing PowerShell 7: one path, and one alternative [Microsoft's install article](https://learn.microsoft.com/en-us/powershell/scripting/install/install-powershell-on-windows?view=powershell-7.6) names WinGet the recommended way to install PowerShell on Windows clients, and the Store package's first listed benefit is **"Automatic updates built right into Windows"**. This plugin offers exactly what that argument supports, and nothing else: | path | what it is | |---|---| | **1. Install from the Microsoft Store** (the plugin's only install path) | `pwsh7_install` runs `winget install --id 9MZ1SNWT0N5D --source msstore` ([Store listing](https://apps.microsoft.com/detail/9mz1snwt0n5d)) as a background job. Self-updating, per-user, no elevation. On a connection marked metered Windows defers it — see below; the remedy is the connection setting, never another installer. | | **2. Disable this plugin** | If PowerShell 7 is not wanted on this machine, turn the plugin off on its page. Its advice and its two tools stop, and nothing is installed. The `pwsh` tool keeps failing while an executor row pins a path that does not exist, so that is a decision about `pwsh` itself. | There is **no `route` parameter** and no second installer in any model-facing text — tool description, report, or prompt section. Two alternatives were measured and rejected during development, and they stay here as history rather than as options: - `winget install --id Microsoft.PowerShell --source winget` (since the 7.6.0 package that is the **MSIX** by default; `--installer-type wix` forces the per-machine MSI). Its AppX stage fetches the 336 MiB bundle a second time and timed out here with `0x80072EE2`; from 7.7.0 there is no MSI at all. - the per-machine MSI, which needs an **interactive elevation** this plugin's host process could not obtain — it failed with `1622`. Neither would be kept up to date by Windows, which is the whole reason the Store is the one path. ### The Store path "queues forever" on a metered connection This is the one trap that cost the most time to find, so it is written down with its evidence (measured 2026-10-14): 1. `winget install --id 9MZ1SNWT0N5D --source msstore` reached `Started MSStore package execution. ProductId: 9MZ1SNWT0N5D` and then simply waited — forever. 2. The Store's own log (`Microsoft-Windows-Store/Operational`) recorded **no warning and no error**, which is why it looks like a hung queue rather than a failure. 3. Its telemetry showed the request carried **`AllowDownloadOnAnyNetwork = 0`**, and the connection reported **`NetworkCostType = Fixed`** — i.e. the Wi-Fi profile was marked *Metered connection*. 4. The only run that progressed was the one a human started in the Store UI (`IsInteractive = 1`, `ClientAppId = …Microsoft.WindowsStore…StartProductInstallWithOptionsForUserAsync`). 5. After the network was un-marked as metered, the **same non-interactive** route finished in under a minute (`IsInteractive = false`, `ClientAppId = …WinGetCli-winget-StartProductInstallWithOptionsAsync`), and winget exited 0 for the first time all day. Windows documents the policy this reflects — metered connections restrict automatic downloads — in [Metered connections in Windows](https://support.microsoft.com/en-us/windows/experience/connectivity-networking/metered-connections-in-windows) ([中文](https://support.microsoft.com/zh-cn/windows/experience/connectivity-networking/metered-connections-in-windows)), and a [Microsoft Q&A thread](https://learn.microsoft.com/en-us/answers/questions/5494248/microsoft-store-stuck-on-queuing-a-download-for-ov) has the same symptom. So `pwsh7_install` reads the connection cost **before** starting the install (one probe through Windows PowerShell 5.1, which is the only PowerShell that reaches WinRT from a script) and says so up front when it is metered, naming the network — and the remedy is always the same single step: - turn off **Metered connection** for that network (Settings → Network & Internet → Wi-Fi → *that network*) and run the install again. No second installer is offered, in the warning or anywhere else in the model-facing text: a metered connection is a connection setting, not a reason to leave the Store. `pwsh7_install` starts the store route anyway and leaves the choice to the user; an unreadable probe produces no warning at all, because the plugin does not guess. ### A failed winget exit is evidence, not a verdict The Store route can also exit non-zero *while the runtime lands anyway* (a measured run exited 1 after twelve minutes and PowerShell 7 was in place shortly after). The plugin therefore re-probes for a bounded while after winget exits (eight probes, 15 s apart) before it reports a failure, and says so on the job's progress line. The verdict is always the runtime, never the exit code: a run that reports `winget exited with code 1` but ends with PowerShell 7 in place is reported as **installed**, with the misleading exit code kept as detail — and the wording no longer claims winget did the work, because it cannot know that. ### Two Store path shapes — only one of them may be pinned | Form | Example | Pinnable? | |---|---|---| | **family alias** | `…\Local\Microsoft\WindowsApps\Microsoft.PowerShell_8wekyb3d8bbwe\pwsh.exe` | **yes** — the directory is the package family name, so it carries no version and survives Store updates | | **root alias** | `…\Local\Microsoft\WindowsApps\pwsh.exe` | **yes** — the same execution-alias mechanism, also free of any version. The executor's own probe tends to return this one inside the host process while a profile row pins the per-family path; both are accepted | | version-numbered package executable | `C:\Program Files\WindowsApps\Microsoft.PowerShell_7.6.6.0_x64__8wekyb3d8bbwe\pwsh.exe` | **no** — every Store update moves it, so a patch pinned to it silently stops working | The alias is a zero-length reparse point (`Length=0`, `ReparsePoint`) that Windows resolves through AppInfo package activation. Measured on a machine running the alias (2026-10-13, PowerShell 7.6.6, current Windows): - the alias spawns under the Harness `pwsh` tool, and - **the ACL sandbox still applies to what it runs**: with the session on `workspace-write`, a write inside the session workspace succeeded, while writes to two normally writable out-of-workspace paths (`%USERPROFILE%\.dsh\…` and a path on another volume) were refused with `UnauthorizedAccessException` and no file was created. Residual risk, stated rather than hidden: package activation is undocumented, so the alias is a "works today" pin and not a contract. The report says so, and `pwsh7_install` is the way off it if a future Windows or Store change breaks it. ### What a MSIX/Store runtime does not do From the same article's limitations list, worth knowing before choosing it: - single-user install only — there is no all-users option, - **no PowerShell remoting (WSMan)**: `Register-PSSessionConfiguration` and friends need write access to `$PSHOME`, which the package blocks, - no AllUsers profile scripts, and no `Set-ExecutionPolicy -Scope LocalMachine`. Choose the MSI instead where those matter (servers, enterprise deployment, Microsoft Update-managed fleets). ## The two tools ``` pwsh7_status [path] report what runs today; validate an explicit path; produce the exact profile patch for a pinnable PowerShell 7 pwsh7_install install PowerShell 7 from the Microsoft Store as a background job and return its id at once — read progress with job_output, stop it with job_kill. No parameters: there is one install path ``` `pwsh7_install` is a side-effecting tool: only ask for it when you mean it. It refuses to double-install (reports "already in place" when an ordinary PowerShell 7 **or** a working Store alias exists) and it never uninstalls PowerShell — uninstalling the plugin leaves your PowerShell alone. The other answer, when PowerShell 7 is not wanted, is to disable the plugin; see [Installing PowerShell 7](#installing-powershell-7-one-path-and-one-alternative). ### Why the install is a background job The first version of this tool `await`ed winget, and on this machine that meant a **silent twelve minute call** — winget had handed the package to the Store, whose download was sitting queued waiting for a click, and the call looked hung until it was abandoned mid-flight. A blocking call cannot show that; a job can. So `pwsh7_install` registers the install with the platform's job registry (`ctx.jobs`, kind `pwsh7`) and returns the job id immediately. Read it with `job_output` and stop it with `job_kill` — the platform's own tools, so the plugin does not ship a progress tool of its own. ### Where the progress comes from winget writes nothing useful to a pipe: with stdout captured it draws no progress bar and prints only a few lines, and one measured run spent **4m15s inside a single silent `StagePackageAsync`**. Its own diagnostic log is the real feed, so the job follows the newest `…\Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState\DiagOutputDir\WinGet-*.log` and turns its phase lines into the progress line, appending the raw lines to the job output: | winget log line | progress line | |---|---| | `Downloading to path:` | updating the winget source | | `Packaged source open` | winget source ready | | `Installer hash verified` | installer downloaded and verified | | `Starting StagePackageAsync` | deploying the package (staging covers the download) | | `Starting RegisterPackageByFullNameAsync` | registering the package | | `Leaf command succeeded` | winget finished | ### Download bytes and speed winget reports **no numeric download progress at all**. That is not a reading problem: a PTY run (the terminal it draws its UI in) showed a spinner, zero percent tokens, and the *indeterminate* taskbar signal `OSC 9;4;3` — winget itself does not know a percentage for this download. So the numbers are measured instead: the installer lands under `%TEMP%\WinGet\.\`, and the job sums those bytes every tick, turning the growth into a speed. When the installer URL (a URL in the log) answers a `HEAD` with a `content-length`, the line also carries a percentage: ``` running 2m — deploying the package (staging covers the download) — downloaded 120.4 MiB / 335.9 MiB (36%) @ 4.5 MiB/s ``` Bytes and speed always show up once anything is on disk; the percentage is the only part that depends on the server saying how large the file is. The log is another program's private format, so this is best-effort by design: if it cannot be found or read, the job falls back to the elapsed-time line — and, past ten minutes, to the route's stall warning. On settle the plugin re-probes for a bounded while (see [above](#a-failed-winget-exit-is-evidence-not-a-verdict)) and writes the verdict into the ring, so a runtime that lands after winget gave up is still reported as installed. ## What the model is told, and why it is not the tool's description The point of pinning PowerShell 7 is that **`&&` and `||` work there and are parse errors in Windows PowerShell 5.1** — so the model has to know which one it is on. The plugin publishes one prompt section, refreshed by the same reading the report uses: ``` The `pwsh` tool on this machine runs PowerShell 7.6.6, so `&&` and `||` work in one command. ``` On a row that pins Windows PowerShell 5.1 — or on a machine with no PowerShell 7 at all, where the executor falls back to 5.1 — the same line says the operators are parse errors and to chain with `;`. A third state gets its own line: when a row pins a `pwshPath` that **no longer exists**, the executor uses the pin and does not fall back, so every call fails with a spawn error — the section says so, and the model can report it on the first try instead of rediscovering it by wasting calls: ``` The `pwsh` tool cannot start: an executor row pins C:\…\pwsh.exe, which does not exist. … ``` Anything else renders nothing: it never guesses. Cost: one line (~25 tokens) on a machine whose executor pins a PowerShell 7, zero in the other normal states, and ~45 tokens only while the tool is actually broken. **Why the section and not the `pwsh` tool's own description** (the seam a reader would expect): - `ctx.tools.register()` inserts into a per-layer name table that throws on a duplicate, and a profile plugin registers in the global layer — re-registering `pwsh` is refused. - the tool registry has no description hook: its extension points are `tools/pre-execute` / `tools/execute` / `tools/post-execute` / `tools/result`, guards, and `restrict()`, none of which touch the model-facing schema. - `systemPrompt.tools(provider)` **concatenates** providers (no dedupe, no override), so a second `pwsh` schema would put two tools of the same name in the request. - shadowing the shipped `tool:pwsh` section through `agent.ctx` would *replace* its exit-code note rather than add to it. So the plugin owns a section beside the shipped one, placed at the shared `TOOL_PWSH` order. ## Configuration (all optional) Add this under the plugin's own row in the profile patch: ```yaml - id: win-pwsh7 config: overridePath: 'D:\tools\pwsh\pwsh.exe' # priority ① — validated; must spawn and report PowerShell 7+ installIfMissing: true # install the Store package through winget at startup when nothing usable exists ``` - `overridePath` is validated on every start; a rejected value falls through to auto-detect and is reported with its reason. A Store family alias is accepted here; a version-numbered Store path is accepted as a runtime but reported as un-pinnable. - `installIfMissing` defaults to `false`. When it triggers, the install runs **inline at activation** (not as a job: the activation sweep is already fire-and-forget), is logged, and is skipped once an ordinary PowerShell 7 or a Store alias exists. ## Example report (this machine: the Store family alias, wired in by a patch) ``` Executor rows: pwsh-sandbox : pwshPath = C:\Users\...\WindowsApps\Microsoft.PowerShell_8wekyb3d8bbwe\pwsh.exe (PowerShell 7.6.6, Store family alias) desktop-windows-pwsh-sandbox : pwshPath = C:\Users\...\WindowsApps\...\pwsh.exe (PowerShell 7.6.6, Store family alias) The pwsh tool currently runs on PowerShell 7.6.6 via row desktop-windows-pwsh-sandbox. (Row `pwsh-sandbox` above pins the same alias but is disabled, and a disabled row does not run — so it is deliberately not the row named here.) That pin is the version-stable Store family alias: its directory carries no version, so Store updates do not move it, and the ACL sandbox still confines what it runs. Nothing to configure. Caveat: Windows resolves the alias through AppInfo package activation, which is undocumented — if a future Windows or Store change stops it, run pwsh7_install, or pin an ordinary pwsh.exe. ``` The same summary is written to the host logs at startup under `dsh-win-pwsh7:`. ## The plugin page The bundle's page gains three pieces, all reading and writing the same Host state as the tools (one operation, two callers): - a **diagnostics section** carrying the same report `pwsh7_status` produces, plus a **Re-detect** action in the page head that refreshes it, - a **configure card** on the row's own page (open it through the row's 配置/Configure control) for `overridePath` and `installIfMissing` — edits are written through configForms and take effect without a restart. The card talks to the Host over the package's build-time typert contract (`lib/typert.host.js`), which the official loader registers at boot. ## Verify ```powershell npm run check # both gates node tools/check-host.mjs # the Host half alone node tools/check-client.mjs # the Client half alone ``` `tools/` ships with the package, so these run on an installed copy too. One thing has to be told where the harness is: the hook that re-parents `@deepseek-ai/*` tries `DSH_APP_DIR` first, then the nearest ancestor of the tools directory or the working directory that carries `node_modules/@deepseek-ai/cordis`. **In practice, set `DSH_APP_DIR`** — a plugin installed into a profile's `node_modules` has no harness packages beside it (measured: this profile's `node_modules` holds no `@deepseek-ai/*`), so discovery has nothing to find there. The client gate needs no such variable: it renders the browser half through its own stubs. ```powershell $env:DSH_APP_DIR = 'C:\Program Files\DSH Desktop\resources\app' npm run check ``` `check-host.mjs` runs the host half's gate offline: it loads `index.js`, applies it to a stub context, and drives both tools through the registry's own success path (`execute` → validate → `render`), the 5.1 / missing-path rejections, the two Store path shapes (a pinned family alias must read as stable wiring; a pinned version-numbered path must be refused), the install path and its two-ways-forward report, the install **as a background job** (the call must return before winget exits; the job must carry both streams, report elapsed time, warn past the stall threshold, and settle with a re-validated verdict), the connection-cost probe (a metered reading must be named; an unreadable one must stay silent), and the runtime prompt section on a pinned PowerShell 7, a pinned Windows PowerShell 5.1, a pin that no longer exists, and an unknown runtime. The behaviour half is fixture-pinned (`PATH`, `LOCALAPPDATA`, `ProgramFiles`) so it runs on any machine — including one that has the per-machine MSI installed, which is where an earlier version silently skipped it — and it drives job cancellation and both inline install fallbacks as well. Two invariants that broke once are asserted in their own right: the stall warning must coexist with a known phase, and the card's `useSyncExternalStore` callbacks must keep their identity across renders. It spawns nothing. `check-client.mjs` **renders** the client half instead of reading it: the browser module is captured through a stub `window.__ModuleLoader__`, given a stub React that records the element tree, and applied to a stub client context, then the assertions read the text a user would see. That is what catches a defect no static scan can see — a label written as a literal (`h('label', null, 'overridePath')`) is valid text and only shows up once the card is drawn. The card's own dictionaries are the fixtures, so no translation is duplicated into the gate. After installing (or applying a patch) and restarting DSH: ```powershell $PSVersionTable.PSVersion ``` and confirm that writing outside the session workspace is still refused — the sandbox must stay intact. ## Releasing ```powershell $env:DSH_APP_DIR = 'C:\Program Files\DSH Desktop\resources\app' # the host gate needs this npm run check # green before anything else # bump "version" in package.json, then: git add -A; git commit -m 'dsh-win-pwsh7 X.Y.Z: ...' git tag vX.Y.Z git push origin main --tags # GitHub first: the tag is what an install pins npm publish # `prepublishOnly` re-runs both gates and aborts on red ``` `prepublishOnly` runs `npm run check`, so a red tree cannot be published; it needs the same `DSH_APP_DIR` this section sets. What ships is the `files` allowlist plus `LICENSE`, `README.md`, `README.en.md` and `CHANGELOG.md` — `npm pack --dry-run` prints the exact list, and it holds no artifact or machine-specific file. ## License [MIT](LICENSE) — free and open source.