Bläddra i källkod

feat(pwsh): add the pwsh-local executor and the pwsh tool

Windows-native execution foundation: PwshLocalExecutor implements the bash
executor seam over ctx.subprocess (pwsh -NoLogo -NoProfile -NonInteractive
-Command, one argv element, no quoting layer; resolvePwshPath probes
PowerShell 7 / PATH / Windows PowerShell 5.1 as a pure function), and
tool-pwsh is the minimal PowerShell-dialect model-facing tool over ctx.bash
(foreground only, managed DSH_* env, timeout/signal/exit markers, terminal
and generic presenters). Both packages carry full suites (real pwsh,
self-skipping without it) at per-file 100% coverage; vitest's Windows
exclusion narrows from packages/bash/* to the bash-requiring packages so the
pwsh suites run natively on Windows too. The CLI gains the workspace deps
and tsconfig projects without mounting either plugin; the Windows-default
roadmap is recorded as a proposed Agent Note.
Huanqi Cao 1 månad sedan
förälder
incheckning
8c6179d69d
34 ändrade filer med 2341 tillägg och 1 borttagningar
  1. 6 0
      .agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml
  2. 35 0
      .agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md
  3. 35 0
      .agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md
  4. 6 0
      .agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml
  5. 41 0
      .agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md
  6. 41 0
      .agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md
  7. 2 0
      apps/cli/package.json
  8. 44 0
      docs/config-catalog.md
  9. 16 0
      docs/module-graph.md
  10. 39 0
      docs/tool-catalog.md
  11. 1 0
      knip.json
  12. 6 0
      packages/bash/pwsh-local/README.i18n.yaml
  13. 53 0
      packages/bash/pwsh-local/README.md
  14. 53 0
      packages/bash/pwsh-local/README.zh.md
  15. 47 0
      packages/bash/pwsh-local/package.json
  16. 316 0
      packages/bash/pwsh-local/src/index.ts
  17. 30 0
      packages/bash/pwsh-local/src/invariant.ts
  18. 412 0
      packages/bash/pwsh-local/tests/executor.spec.ts
  19. 36 0
      packages/bash/pwsh-local/tsconfig.json
  20. 6 0
      packages/bash/tool-pwsh/README.i18n.yaml
  21. 107 0
      packages/bash/tool-pwsh/README.md
  22. 107 0
      packages/bash/tool-pwsh/README.zh.md
  23. 56 0
      packages/bash/tool-pwsh/package.json
  24. 254 0
      packages/bash/tool-pwsh/src/index.ts
  25. 30 0
      packages/bash/tool-pwsh/src/invariant.ts
  26. 119 0
      packages/bash/tool-pwsh/tests/integration.spec.ts
  27. 296 0
      packages/bash/tool-pwsh/tests/tools.spec.ts
  28. 45 0
      packages/bash/tool-pwsh/tsconfig.json
  29. 71 0
      pnpm-lock.yaml
  30. 19 0
      scripts/gen-tool-catalog.ts
  31. 1 0
      scripts/verify-package-readme-model-experience.ts
  32. 2 0
      tsconfig.base.json
  33. 2 0
      tsconfig.host.json
  34. 7 1
      vitest.config.ts

+ 6 - 0
.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md
+2026-08-01-pwsh-tool-and-executor.md: fd73e929804045d7b810a587c6f088b2f7cff9cc
+2026-08-01-pwsh-tool-and-executor.zh.md: f55be1ad0e102311d09b8b7ee1a003778e679cb2

+ 35 - 0
.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md

@@ -0,0 +1,35 @@
+# Agent Note: PowerShell executor and pwsh tool
+
+Status: implemented
+
+English | [中文](2026-08-01-pwsh-tool-and-executor.zh.md)
+
+## Problem
+
+The harness spoke one shell dialect on every platform: `bash`. Windows hosts could run it only through WSL or Git-Bash shims, and the shipped `dsh-bash-local` executor is POSIX-only (`bash` hardcoded, process-group semantics POSIX). The Windows roadmap — defaulting hosts to `pwsh`, later pwsh TUI/GUI rendering — had no execution foundation: there was no PowerShell implementation of the bash executor seam and no model-facing tool that taught the PowerShell dialect. The bash tool itself is also far larger than a Windows-first profile needs: background tasks, sandbox escalation, and the persistent-PTY twin are all bash-shaped surface that a minimal `pwsh` tool should not carry.
+
+## Decision
+
+Two new packages under `packages/bash/`:
+
+- **`@deepseek-ai/dsh-pwsh-local`** — a local implementation of the `ctx.bash` executor seam over `ctx.subprocess`, mirroring `dsh-bash-local` call-for-call: `resolve()` defaults and caps from config, `run()` fuses the config-clamped timeout with the caller's signal through one deadline, `start()` returns a consuming background handle whose processes belong to the subprocess service. The command string rides as ONE argv element to `pwsh -NoLogo -NoProfile -NonInteractive -Command`, so PowerShell parses it and no shell-quoting layer exists. Executable resolution (`resolvePwshPath`) is a pure function of `(configured, env, platform)`: explicit config first, then Windows probes PowerShell 7's install, PATH entries (quotes stripped), and Windows PowerShell 5.1, else a bare `pwsh` via PATH.
+- **`@deepseek-ai/dsh-tool-pwsh`** — the minimal model-facing tool over `ctx.bash`, PowerShell-dialect by contract: foreground only, no `run_in_background`, no sandbox escalation, managed `DSH_*` environment (`DSH_HOME`, `DSH_SHELL=1`, `DSH_SESSION_ID`), result markers `[exit code: N]` / `[timed out after …]` / `[killed by signal: …]`, and `terminal`/`generic` UI presenters.
+
+Windows vitest coverage is deliberately NOT part of this change: the repo's Windows CI lane owns build/static gates, and unit coverage runs on Linux, where both packages' suites run against a real `pwsh` (preinstalled on the GitHub-hosted runners) or self-skip when absent. The vitest `windowsUnsupportedPackages` exclusion narrows from `packages/bash/*` to the bash-requiring packages so the pwsh suites can also run natively on Windows dev machines.
+
+The roadmap beyond this decision — defaulting Windows hosts to `pwsh` (bash off), and pwsh TUI/GUI rendering — is recorded separately as [a proposal](../../proposed/feature/2026-08-01-windows-pwsh-default.md).
+
+## Alternatives considered
+
+**Extend `dsh-bash-local` with a pwsh mode.** Rejected: the executor's identity is the shell it spawns; a second dialect inside one package doubles its config surface (`shell` switches) and its test matrix, and the two dialects' quirks (signal facts on Windows, quoting domains) belong to their own packages' documentation.
+
+**Extend `dsh-tool-bash` with a dialect parameter.** Rejected: the bash tool's background/sandbox surface is bash-shaped; a `pwsh` mode would either hide it (conditional schema churn) or inherit it (surface the minimal profile explicitly rejects). The minimal twin keeps the model contract honest.
+
+**Wire the pwsh tool into the shipped CLI compositions now.** Rejected: mounting `tool-pwsh` + `pwsh-local` in `base.cordis.yml` would change the shipped roster before the Windows-default decision lands; this change ships the capability and its wiring points (`apps/cli` dependencies, tsconfig projects) without switching any default.
+
+## Consequences
+
+- The bash executor seam gains a second, Windows-native implementation with an identical request/spec contract, so model-facing consumers beyond `tool-pwsh` (hooks bridges, in-process plugins) can run PowerShell without dialect shims.
+- `tool-pwsh` is the model-visible Windows-first profile: no background tasks or escalation to mislead a model into assuming bash-tool parity, and the prompt guidance pins the `[exit code: N]` contract.
+- Windows semantics differ where the platform differs: forced termination reports exit 1 with no signal (so `signal`/`killed` status facts are POSIX-only), and PowerShell writes CRLF, which tests normalize.
+- The CLI gains two workspace dependencies and two tsconfig projects without mounting either plugin — the composition decision stays with the Windows-default proposal.

+ 35 - 0
.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: PowerShell 执行器与 pwsh 工具
+
+Status: implemented
+
+[English](2026-08-01-pwsh-tool-and-executor.md) | 中文
+
+## 问题
+
+harness 在每个平台只说一种 shell 方言:`bash`。Windows 主机只能通过 WSL 或 Git-Bash 垫片运行它,而交付的 `dsh-bash-local` 执行器仅限 POSIX(硬编码 `bash`,进程组语义是 POSIX 的)。Windows 路线图——让主机默认 `pwsh`,之后再做 pwsh TUI/GUI 渲染——没有执行基础:既没有 bash 执行器 seam 的 PowerShell 实现,也没有教模型 PowerShell 方言的面向模型工具。bash 工具本身也远大于 Windows 优先画像所需:后台任务、沙箱升级与持久 PTY 孪生都是 bash 形状的表面,最小化的 `pwsh` 工具不该背负。
+
+## 决策
+
+在 `packages/bash/` 下新增两个包:
+
+- **`@deepseek-ai/dsh-pwsh-local`** —— `ctx.bash` 执行器 seam 的本地实现,基于 `ctx.subprocess`,逐调用镜像 `dsh-bash-local`:`resolve()` 从配置默认化并设上限,`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号,`start()` 返回消费式后台句柄,其进程归属于 subprocess 服务。命令字符串作为 ONE argv 元素传给 `pwsh -NoLogo -NoProfile -NonInteractive -Command`,由 PowerShell 解析,不存在 shell 引号层。可执行文件解析(`resolvePwshPath`)是 `(configured, env, platform)` 的纯函数:先显式配置,再在 Windows 上探测 PowerShell 7 安装位置、PATH 条目(剥离引号)与 Windows PowerShell 5.1,否则经 PATH 解析裸 `pwsh`。
+- **`@deepseek-ai/dsh-tool-pwsh`** —— 基于 `ctx.bash` 的最小面向模型工具,契约是 PowerShell 方言:仅前台,没有 `run_in_background`,没有沙箱升级,受管 `DSH_*` 环境(`DSH_HOME`、`DSH_SHELL=1`、`DSH_SESSION_ID`),结果标记 `[exit code: N]` / `[timed out after …]` / `[killed by signal: …]`,以及 `terminal`/`generic` UI presenter。
+
+Windows vitest 覆盖率刻意不属本次改动:仓库的 Windows CI 通道负责构建/静态门禁,单元覆盖在 Linux 上运行,两个包的套件在那里以真实 `pwsh` 运行(GitHub 托管 runner 预装)或缺失时自行跳过。vitest 的 `windowsUnsupportedPackages` 排除从 `packages/bash/*` 收窄为真正需要 bash 的包,使 pwsh 套件也能在 Windows 开发机上原生运行。
+
+本决策之后的路线图——让 Windows 主机默认 `pwsh`(关闭 bash)与 pwsh TUI/GUI 渲染——另行记录为[提案](../../proposed/feature/2026-08-01-windows-pwsh-default.md)。
+
+## 备选方案
+
+**给 `dsh-bash-local` 增加 pwsh 模式。** 否决:执行器的身份就是它 spawn 的 shell;在一个包内塞第二种方言会翻倍配置面(`shell` 开关)与测试矩阵,且两种方言的怪癖(Windows 上的信号实情、引号域)应各自归入自己包的文档。
+
+**给 `dsh-tool-bash` 增加方言参数。** 否决:bash 工具的后台/沙箱表面是 bash 形状的;`pwsh` 模式要么隐藏它(条件 schema 翻动),要么继承它(把最小画像明确拒绝的表面带进来)。最小孪生让模型契约保持诚实。
+
+**现在就接入交付的 CLI 组合。** 否决:在 Windows 默认决策落地前把 `tool-pwsh` + `pwsh-local` 挂进 `base.cordis.yml` 会改变交付清单;本改动交付能力与接线点(`apps/cli` 依赖、tsconfig 工程),不切换任何默认。
+
+## 后果
+
+- bash 执行器 seam 有了第二个、Windows 原生的实现,请求/规范契约一致,因此 `tool-pwsh` 之外的面向模型消费方(hooks 桥、进程内插件)无需方言垫片即可运行 PowerShell。
+- `tool-pwsh` 是模型可见的 Windows 优先画像:没有后台任务或升级会让模型误以为与 bash 工具对等,提示词指导钉住 `[exit code: N]` 契约。
+- Windows 语义在平台差异处不同:强制终止报告退出码 1 且无信号(因此 `signal`/`killed` 状态实情仅限 POSIX),PowerShell 输出 CRLF,测试做归一化。
+- CLI 增加两个 workspace 依赖与两个 tsconfig 工程,但不挂载任一插件——组合决策留给 Windows 默认提案。

+ 6 - 0
.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md
+2026-08-01-windows-pwsh-default.md: 6f3e48f33d98b2d2da7bd288d42a0d2763163ba3
+2026-08-01-windows-pwsh-default.zh.md: 270fd8d95c85400c302540376932228a6023447c

+ 41 - 0
.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md

@@ -0,0 +1,41 @@
+# Agent Note: Windows defaults to pwsh (roadmap)
+
+Status: proposed
+
+English | [中文](2026-08-01-windows-pwsh-default.zh.md)
+
+## Problem
+
+The harness's shipped execution profile is bash-first on every platform. Windows hosts must install a bash shim (WSL or Git-Bash) or fall back to the POSIX-only `dsh-bash-local` behavior; the model-facing bash tool teaches the bash dialect, and the TUI/Web surfaces render terminal output in bash-shaped expectations. The first Windows-native foundation shipped in the [pwsh executor and tool decision](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md): a PowerShell implementation of the `ctx.bash` seam and a minimal `pwsh` tool — but nothing yet defaults Windows hosts to them.
+
+## Proposal
+
+Three follow-up stages, each independently shippable:
+
+1. **Windows default composition** — the shipped CLI compositions mount `dsh-pwsh-local` as the `ctx.bash` executor and `dsh-tool-pwsh` as the model-facing shell tool on Windows hosts (bash unmounted there), while POSIX hosts keep the bash stack. This is a composition/roster decision in `base.cordis.yml` and the surface overlays, gated by platform; it makes the shipped Windows experience PowerShell-native end to end.
+2. **Bash-tool parity twin** — `tool-pwsh` grows the bash tool's missing surface where Windows workflows prove it: `run_in_background` through the generic task runtime, and the persistence-side `DSH_SESSION_JSONL` environment fact. Sandbox escalation stays out until a Windows-confining executor exists.
+3. **pwsh TUI/GUI rendering** — the TUI and Web surfaces render pwsh output with PowerShell-aware presentation (native path display, `$env:` facts), the counterpart of the bash terminal cards. This is where terminal/console rendering conventions get a PowerShell twin.
+
+The stages are deliberately sequenced: composition first (a Windows user gets PowerShell without choosing), then tool parity, then rendering. Nothing in this proposal changes POSIX behavior.
+
+## Alternatives considered
+
+**Default Windows to pwsh inside `dsh-bash-local` (one executor, dialect switch).** Rejected for the same reason the executor decision rejected a mode switch: the executor's identity is the shell it spawns, and platform-gated composition is a deployment choice, not an executor config.
+
+**Ship the Windows default in the same change as the executor/tool.** Rejected: the roster change needs its own evidence (what breaks when the shipped Windows tree stops mounting bash, which tools depend on bash semantics), and it belongs to a composition decision with the approval/PTY surface visible.
+
+**Keep bash on Windows via a shim and skip PowerShell defaults.** Rejected: it perpetuates the install-tax and the dialect mismatch the roadmap exists to remove; the shim is a deployment requirement, not a product behavior.
+
+## Acceptance criteria
+
+- A Windows host running the shipped `dsh` TUI/Web gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration, and `bash` is absent from the model-visible roster there.
+- POSIX hosts are byte-for-byte unaffected (same roster, same executor).
+- The shipped-composition e2es assert the platform-gated roster on both families.
+- Stage 2 lands with task-runtime integration tests; stage 3 lands with TUI/Web rendering snapshots for pwsh output.
+
+## Risks
+
+- **Bash-dependent composition rows** — any shipped plugin that assumes `bash` semantics (hook bridges executing shell hooks, workspace tooling) must be audited per stage; the audit may force a staged rollout rather than one switch.
+- **Tool-behavior drift** — a minimal `tool-pwsh` that never grows parity invites models to write bash-shaped commands; the prompt guidance and dialect contract mitigate this only if the twin keeps pace.
+- **Windows CI coverage gap** — unit coverage runs on Linux; Windows-only regressions in the pwsh stack surface through the Windows build/static lane and e2es, which must be extended per stage rather than assumed.
+- **Rendering conventions** — a PowerShell twin for terminal cards is a UI design decision with snapshot surface; deferring it (stage 3) keeps stage 1 shippable without UI churn.

+ 41 - 0
.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md

@@ -0,0 +1,41 @@
+# Agent Note: Windows 默认改用 pwsh(路线图)
+
+Status: proposed
+
+[English](2026-08-01-windows-pwsh-default.md) | 中文
+
+## 问题
+
+harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机必须安装 bash 垫片(WSL 或 Git-Bash),或退回到仅 POSIX 的 `dsh-bash-local` 行为;面向模型的 bash 工具教的是 bash 方言,TUI/Web 表面以 bash 形状的预期渲染终端输出。第一块 Windows 原生基础已随 [pwsh 执行器与工具决策](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md) 交付:`ctx.bash` seam 的 PowerShell 实现与最小化的 `pwsh` 工具——但还没有任何东西让 Windows 主机默认使用它们。
+
+## 提案
+
+三个阶段,各自可独立交付:
+
+1. **Windows 默认组合**——交付的 CLI 组合在 Windows 主机上挂载 `dsh-pwsh-local` 作为 `ctx.bash` 执行器、`dsh-tool-pwsh` 作为面向模型的 shell 工具(那里不挂载 bash),POSIX 主机保持 bash 栈。这是 `base.cordis.yml` 与 surface 覆盖层里按平台门控的组合/清单决策;它让交付的 Windows 体验端到端 PowerShell 原生。
+2. **bash 工具对等孪生**——在 Windows 工作流证明需要的地方,`tool-pwsh` 补齐 bash 工具缺失的表面:经由通用任务运行时的 `run_in_background`,以及持久化侧 `DSH_SESSION_JSONL` 环境实情。在出现 Windows 约束执行器之前,沙箱升级保持缺席。
+3. **pwsh TUI/GUI 渲染**——TUI 与 Web 表面以 PowerShell 感知的呈现渲染 pwsh 输出(原生路径显示、`$env:` 实情),即 bash 终端卡片的对应物。这是终端/控制台渲染约定获得 PowerShell 孪生的地方。
+
+各阶段刻意排序:先组合(Windows 用户无需选择即获得 PowerShell),再工具对等,最后渲染。本提案不改变任何 POSIX 行为。
+
+## 备选方案
+
+**在 `dsh-bash-local` 内部让 Windows 默认 pwsh(一个执行器,方言开关)。** 否决,理由与执行器决策否决模式开关相同:执行器的身份就是它 spawn 的 shell,而按平台门控的组合是部署选择,不是执行器配置。
+
+**把 Windows 默认与执行器/工具一起交付。** 否决:清单变更需要自己的证据(交付的 Windows 树停挂 bash 后什么会坏、哪些工具依赖 bash 语义),并且它属于带批准/PTY 表面可见的组合决策。
+
+**用垫片在 Windows 上保留 bash,跳过 PowerShell 默认。** 否决:这延续了安装税与路线图要消除的方言错配;垫片是部署要求,不是产品行为。
+
+## 验收标准
+
+- 运行交付版 `dsh` TUI/Web 的 Windows 主机无需配置即获得 `pwsh` 作为其 shell 工具、PowerShell 作为 `ctx.bash` 执行器,且那里的模型可见清单中没有 `bash`。
+- POSIX 主机逐字节不受影响(清单相同,执行器相同)。
+- 交付组合 e2e 在两个平台族上断言按平台门控的清单。
+- 阶段 2 附带任务运行时集成测试落地;阶段 3 附带 pwsh 输出的 TUI/Web 渲染快照落地。
+
+## 风险
+
+- **依赖 bash 的组合行**——任何假设 bash 语义的交付插件(执行 shell hooks 的 hooks 桥、工作区工具)必须按阶段审计;审计可能迫使分阶段推出而非一次切换。
+- **工具行为漂移**——永远不补齐对等的 `tool-pwsh` 会诱使模型写 bash 形状的命令;只有当孪生跟上节奏时,提示词指导与方言契约才能缓解这一点。
+- **Windows CI 覆盖缺口**——单元覆盖在 Linux 上运行;pwsh 栈里仅 Windows 的回归通过 Windows 构建/静态通道与 e2e 浮出,必须按阶段扩展而不是想当然。
+- **渲染约定**——终端卡片的 PowerShell 孪生是带快照表面的 UI 设计决策;把它延期(阶段 3)让阶段 1 无需 UI 翻动即可交付。

+ 2 - 0
apps/cli/package.json

@@ -77,6 +77,7 @@
     "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^",
     "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^",
     "@deepseek-ai/dsh-pty": "workspace:^",
     "@deepseek-ai/dsh-pty": "workspace:^",
     "@deepseek-ai/dsh-pty-local": "workspace:^",
     "@deepseek-ai/dsh-pty-local": "workspace:^",
+    "@deepseek-ai/dsh-pwsh-local": "workspace:^",
     "@deepseek-ai/dsh-sandbox-local": "workspace:^",
     "@deepseek-ai/dsh-sandbox-local": "workspace:^",
     "@deepseek-ai/dsh-sandbox-policy": "workspace:^",
     "@deepseek-ai/dsh-sandbox-policy": "workspace:^",
     "@deepseek-ai/dsh-scope": "workspace:^",
     "@deepseek-ai/dsh-scope": "workspace:^",
@@ -120,6 +121,7 @@
     "@deepseek-ai/dsh-tool-skill": "workspace:^",
     "@deepseek-ai/dsh-tool-skill": "workspace:^",
     "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
     "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
     "@deepseek-ai/dsh-tool-subagent": "workspace:^",
     "@deepseek-ai/dsh-tool-subagent": "workspace:^",
+    "@deepseek-ai/dsh-tool-pwsh": "workspace:^",
     "@deepseek-ai/dsh-tool-tasks": "workspace:^",
     "@deepseek-ai/dsh-tool-tasks": "workspace:^",
     "@deepseek-ai/dsh-tool-todo": "workspace:^",
     "@deepseek-ai/dsh-tool-todo": "workspace:^",
     "@deepseek-ai/dsh-tool-web": "workspace:^",
     "@deepseek-ai/dsh-tool-web": "workspace:^",

+ 44 - 0
docs/config-catalog.md

@@ -963,6 +963,36 @@ export interface Config {
 
 
 Source: [`packages/pty/pty-local/src/config.ts:6`](../packages/pty/pty-local/src/config.ts)
 Source: [`packages/pty/pty-local/src/config.ts:6`](../packages/pty/pty-local/src/config.ts)
 
 
+## `@deepseek-ai/dsh-pwsh-local`
+
+Requires: `subprocess`
+
+```ts config-catalog
+/** Plugin config (all optional — `static Config` supplies the defaults). */
+export interface Config {
+  /** Default working directory for commands (default: process.cwd()). */
+  cwd?: string
+  /** Default foreground timeout in milliseconds. */
+  timeoutMs?: number
+  /** Upper bound for per-call timeout overrides. */
+  maxTimeoutMs?: number
+  /** Per-stream in-memory output cap; overflow spills to a temp file. */
+  maxOutputBytes?: number
+  /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
+  maxSpillBytes?: number
+  /** Grace period for kill escalation and for inherited pipes after shell exit. */
+  graceMs?: number
+  /**
+   * Explicit pwsh executable. When omitted, well-known Windows install
+   * locations are probed first (PowerShell 7, then Windows PowerShell 5.1),
+   * falling back to a bare `pwsh` resolved through PATH.
+   */
+  pwshPath?: string
+}
+```
+
+Source: [`packages/bash/pwsh-local/src/index.ts:43`](../packages/bash/pwsh-local/src/index.ts)
+
 ## `@deepseek-ai/dsh-repeat-tool-guard`
 ## `@deepseek-ai/dsh-repeat-tool-guard`
 
 
 ```ts config-catalog
 ```ts config-catalog
@@ -1788,6 +1818,20 @@ export interface Config {
 
 
 Source: [`packages/pty/tool-pty/src/index.ts:35`](../packages/pty/tool-pty/src/index.ts)
 Source: [`packages/pty/tool-pty/src/index.ts:35`](../packages/pty/tool-pty/src/index.ts)
 
 
+## `@deepseek-ai/dsh-tool-pwsh`
+
+Requires: `tools` · `bash` · `systemPrompt`
+
+```ts config-catalog
+/** Plugin config (currently empty; kept as a schema so deployments can grow it). */
+export interface Config {
+  /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */
+  dshHome?: string
+}
+```
+
+Source: [`packages/bash/tool-pwsh/src/index.ts:31`](../packages/bash/tool-pwsh/src/index.ts)
+
 ## `@deepseek-ai/dsh-tool-ralph`
 ## `@deepseek-ai/dsh-tool-ralph`
 
 
 Requires: `tools` · `workflows` · `subagents` · `systemPrompt`
 Requires: `tools` · `workflows` · `subagents` · `systemPrompt`

+ 16 - 0
docs/module-graph.md

@@ -40,7 +40,9 @@ flowchart TD
     pkg_bash["bash"]
     pkg_bash["bash"]
     pkg_bash_local["bash-local"]
     pkg_bash_local["bash-local"]
     pkg_bash_sandbox["bash-sandbox"]
     pkg_bash_sandbox["bash-sandbox"]
+    pkg_pwsh_local["pwsh-local"]
     pkg_tool_bash["tool-bash"]
     pkg_tool_bash["tool-bash"]
+    pkg_tool_pwsh["tool-pwsh"]
   end
   end
   subgraph group_fs["packages/fs"]
   subgraph group_fs["packages/fs"]
     pkg_fs["fs"]
     pkg_fs["fs"]
@@ -505,6 +507,10 @@ flowchart TD
   pkg_bash_local --> pkg_invariants
   pkg_bash_local --> pkg_invariants
   pkg_bash_local --> pkg_subprocess
   pkg_bash_local --> pkg_subprocess
   pkg_bash_local --> pkg_timeout
   pkg_bash_local --> pkg_timeout
+  pkg_pwsh_local --> pkg_bash
+  pkg_pwsh_local --> pkg_invariants
+  pkg_pwsh_local --> pkg_subprocess
+  pkg_pwsh_local --> pkg_timeout
   pkg_fs_local --> pkg_fs
   pkg_fs_local --> pkg_fs
   pkg_fs_local --> pkg_invariants
   pkg_fs_local --> pkg_invariants
   pkg_fs_policy --> pkg_fs
   pkg_fs_policy --> pkg_fs
@@ -704,6 +710,14 @@ flowchart TD
   pkg_tool_bash --> pkg_tasks
   pkg_tool_bash --> pkg_tasks
   pkg_tool_bash --> pkg_tools
   pkg_tool_bash --> pkg_tools
   pkg_tool_bash --> pkg_user_approval
   pkg_tool_bash --> pkg_user_approval
+  pkg_tool_pwsh --> pkg_agent
+  pkg_tool_pwsh --> pkg_bash
+  pkg_tool_pwsh --> pkg_invariants
+  pkg_tool_pwsh --> pkg_llm
+  pkg_tool_pwsh --> pkg_paths
+  pkg_tool_pwsh --> pkg_session_persistence
+  pkg_tool_pwsh --> pkg_system_prompt
+  pkg_tool_pwsh --> pkg_tools
   pkg_tool_fs --> pkg_fs
   pkg_tool_fs --> pkg_fs
   pkg_tool_fs --> pkg_invariants
   pkg_tool_fs --> pkg_invariants
   pkg_tool_fs --> pkg_llm
   pkg_tool_fs --> pkg_llm
@@ -1134,6 +1148,7 @@ flowchart TD
 | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) |
 | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) |
 | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) |
 | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) |
 | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
 | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
+| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
 | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
 | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
 | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
 | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
 | [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) |
 | [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) |
@@ -1175,6 +1190,7 @@ flowchart TD
 | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
 | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
+| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
 | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
 | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) |
 | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) |

+ 39 - 0
docs/tool-catalog.md

@@ -19,6 +19,7 @@ This table connects model-visible tool names to the plugin package and service s
 | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. |
 | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. |
 | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. |
 | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. |
 | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. |
 | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. |
+| `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); minimal by design — foreground only, no sandbox escalation, native `C:\...` paths and `$env:NAME` variables. |
 | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. |
 | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. |
 | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. |
 | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. |
 | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after successful file operations`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. |
 | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after successful file operations`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. |
@@ -205,6 +206,44 @@ Source: [`packages/bash/tool-bash/src/index.ts`](../packages/bash/tool-bash/src/
 
 
 The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled.
 The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled.
 
 
+## `@deepseek-ai/dsh-tool-pwsh`
+
+### `pwsh`
+
+Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\...`); read environment variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available.
+
+```json
+{
+  "type": "object",
+  "properties": {
+    "command": {
+      "type": "string",
+      "description": "The PowerShell command to execute."
+    },
+    "description": {
+      "type": "string",
+      "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"Get-Process\" → \"List running processes\"."
+    },
+    "timeoutMs": {
+      "type": "number",
+      "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."
+    },
+    "workdir": {
+      "type": "string",
+      "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."
+    }
+  },
+  "required": [
+    "command",
+    "description"
+  ]
+}
+```
+
+Source: [`packages/bash/tool-pwsh/src/index.ts`](../packages/bash/tool-pwsh/src/index.ts)
+
+The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); minimal by design — foreground only, no sandbox escalation, native `C:\...` paths and `$env:NAME` variables.
+
 ## `@deepseek-ai/dsh-tool-cordis`
 ## `@deepseek-ai/dsh-tool-cordis`
 
 
 ### `cordis_inspect`
 ### `cordis_inspect`

+ 1 - 0
knip.json

@@ -5,6 +5,7 @@
   ],
   ],
   "ignoreBinaries": [
   "ignoreBinaries": [
     "bwrap",
     "bwrap",
+    "pwsh",
     "python3",
     "python3",
     "sandbox-exec",
     "sandbox-exec",
     "taskkill"
     "taskkill"

+ 6 - 0
packages/bash/pwsh-local/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write packages/bash/pwsh-local/README.md
+README.md: a97612ab4e11bf4a3fcfb77daf0624a894b02ad4
+README.zh.md: d6751dac6df789eec9727c1380f1a4c91da60728

+ 53 - 0
packages/bash/pwsh-local/README.md

@@ -0,0 +1,53 @@
+# @deepseek-ai/dsh-pwsh-local
+
+English | [中文](README.zh.md)
+
+Local PowerShell implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `PwshLocalExecutor` spawns `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` per call as a managed process through `ctx.subprocess`, and owns everything PowerShell-shaped — executable resolution, command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's.
+
+The command string rides as ONE argv element to `-Command`: PowerShell itself parses the text, and no intermediate shell exists, so there is no shell-quoting layer to escape (the `bash -c` string domain has no equivalent here). Native Win32 paths (`C:\...`) pass through unchanged.
+
+The package root exports the default and named `PwshLocalExecutor` plugin, its `Config`, and the pure `resolvePwshPath`/`candidatePwshPaths` helpers.
+
+## Config
+
+```yaml
+- id: bash
+  name: '@deepseek-ai/dsh-pwsh-local'
+  config:
+    cwd: C:\path\to\workspace   # default: process.cwd()
+    timeoutMs: 120000           # default foreground timeout
+    maxTimeoutMs: 600000        # cap for per-call overrides
+    maxOutputBytes: 64000       # per-stream in-memory cap; overflow spills to disk
+    maxSpillBytes: 67108864     # per-stream full-output spill cap
+    graceMs: 3000               # kill escalation and post-exit pipe-drain grace
+    pwshPath: C:\Program Files\PowerShell\7\pwsh.exe  # explicit executable; else well-known locations, then PATH
+```
+
+## Behavior (and where it came from)
+
+The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantics call-for-call:
+
+- **Spawn per call, no shell state** — every call is a fresh non-interactive `pwsh -Command` (deterministic; no profile files). The `-NoLogo -NoProfile -NonInteractive` flags disable startup banners, profile loading, and prompts that would garble tool output.
+- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)` and happens once at construction.
+- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
+- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent.
+- **Model-friendly terminal env** — `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb`: that is a POSIX concept; `NO_COLOR` is honored by modern PowerShell renderers) merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins.
+- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies, and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry.
+
+## Model Experience
+
+Indirectly, through `dsh-tool-pwsh`, which renders this executor's bounded stdout/stderr tails, background-process deltas, spill-file paths, and infrastructure failures.
+
+#### KV Cache effect
+
+No direct invalidation; the named consumer owns any request-prefix changes.
+
+## Known Limitations and Deferred Work
+
+- **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose a sandboxing bash executor or policy instead.
+- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; interactive terminal sessions remain deferred until the roadmap's pwsh TUI/GUI rendering work lands.
+- **The command string is PowerShell text** — the `-Command` domain has no shell-quoting layer, but a model-facing command is parsed by PowerShell itself, so PowerShell syntax errors are command failures, not launch failures.
+- **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it.
+- **Windows termination reports no signal** — a force-killed process settles as exit 1 with `signal: null`, so signal-based status classification (POSIX `killed`) does not apply on Windows; `kill()`-initiated stops still stamp `killed` directly.
+
+Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics.

+ 53 - 0
packages/bash/pwsh-local/README.zh.md

@@ -0,0 +1,53 @@
+# @deepseek-ai/dsh-pwsh-local
+
+[English](README.md) | 中文
+
+`@deepseek-ai/dsh-bash` 执行器 seam 的本地 PowerShell 实现,基于 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务:`PwshLocalExecutor` 每次调用以受管进程的方式通过 `ctx.subprocess` spawn `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>`,并拥有所有 PowerShell 形状的职责——可执行文件解析、命令默认化与上限、超时/取消分类、面向模型的终端环境,以及后台读取的 stdout/stderr 合并。进程组机制(有界 spill 输出、凭据清理、终止升级、销毁)属于 subprocess 服务。
+
+命令字符串作为 ONE argv 元素传给 `-Command`:由 PowerShell 自己解析文本,不存在中间 shell,因此没有需要转义的 shell 引号层(`bash -c` 字符串域在这里没有对应物)。原生 Win32 路径(`C:\...`)原样通过。
+
+包根导出默认与具名 `PwshLocalExecutor` 插件、其 `Config`,以及纯函数 `resolvePwshPath`/`candidatePwshPaths` 辅助函数。
+
+## 配置
+
+```yaml
+- id: bash
+  name: '@deepseek-ai/dsh-pwsh-local'
+  config:
+    cwd: C:\path\to\workspace   # default: process.cwd()
+    timeoutMs: 120000           # default foreground timeout
+    maxTimeoutMs: 600000        # cap for per-call overrides
+    maxOutputBytes: 64000       # per-stream in-memory cap; overflow spills to disk
+    maxSpillBytes: 67108864     # per-stream full-output spill cap
+    graceMs: 3000               # kill escalation and post-exit pipe-drain grace
+    pwshPath: C:\Program Files\PowerShell\7\pwsh.exe  # explicit executable; else well-known locations, then PATH
+```
+
+## 行为(及其由来)
+
+作为 `dsh-bash-local` 的 Windows 对应物,逐调用地镜像其语义:
+
+- **每次调用新建进程,无 shell 状态**——每次调用都是全新的非交互 `pwsh -Command`(确定性;不加载 profile 文件)。`-NoLogo -NoProfile -NonInteractive` 关闭启动横幅、profile 加载与会干扰工具输出的提示符。
+- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数,在构造时执行一次。
+- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。
+- **超时与取消分类**——`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。Windows 将强制终止报告为退出码 1 且无信号,因此基于信号的实情(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。
+- **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。
+- **后台进程**——`start()` 立即返回存活的 `BashProcess` 句柄,不设超时;句柄的 `readOutput()` 把服务基于偏移的 stdout/stderr 读取合并为带标记分段的增量与消费游标。仍在运行的进程属于 subprocess 服务,因此它跨执行器重载存活,并随服务销毁(被终止并 join)。一切任务形状的职责(id、所有权、轮询、通知)都在通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md) 中,由工具层把句柄注册进去——本执行器从不接触会话或注册表。
+
+## 模型体验
+
+间接地,经由 `dsh-tool-pwsh` 呈现本执行器的有界 stdout/stderr 尾部、后台进程增量、spill 文件路径与基础设施失败。
+
+#### KV Cache 影响
+
+无直接失效;具名消费方拥有请求前缀的任何变更。
+
+## 已知局限与延期工作
+
+- **自身不设沙箱**——本执行器始终以 harness 进程的权限运行命令;需要约束的部署应组合沙箱化 bash 执行器或策略。
+- **无持久 shell 或 PTY**——每次调用都是全新的 `pwsh -Command`;交互式终端会话在路线图的 pwsh TUI/GUI 渲染工作落地之前保持延期。
+- **命令字符串是 PowerShell 文本**——`-Command` 域没有 shell 引号层,但面向模型的命令由 PowerShell 自己解析,因此 PowerShell 语法错误是命令失败,而非启动失败。
+- **后台 spawn 失败提示只投递一次**——subprocess 服务不会为从未运行的进程缓冲输出,因此执行器只把 `spawn failed: …` 注入一次 `readOutput()` 增量;丢弃该增量的读取方无法恢复它。
+- **Windows 终止不报告信号**——被强制终止的进程以退出码 1、`signal: null` 结束,因此基于信号的状态分类(POSIX `killed`)在 Windows 上不适用;`kill()` 发起的停止仍会直接盖上 `killed`。
+
+清理启发式与 spill 保留的注意事项由 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 持有,它拥有这些机制。

+ 47 - 0
packages/bash/pwsh-local/package.json

@@ -0,0 +1,47 @@
+{
+  "name": "@deepseek-ai/dsh-pwsh-local",
+  "description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./invariant": {
+      "types": "./lib/types/invariant.d.ts",
+      "default": "./lib/invariant.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-bash": "^0.0.1",
+    "@deepseek-ai/dsh-invariants": "^0.0.1",
+    "@deepseek-ai/dsh-subprocess": "^0.0.1",
+    "@deepseek-ai/dsh-timeout": "^0.0.1",
+    "cordis": "^4.0.0-rc.7"
+  },
+  "dependencies": {
+    "schemastery": "^3.18.0"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-bash": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-subprocess": "workspace:^",
+    "@deepseek-ai/dsh-subprocess-local": "workspace:^",
+    "@deepseek-ai/dsh-timeout": "workspace:^",
+    "cordis": "^4.0.0-rc.7"
+  }
+}

+ 316 - 0
packages/bash/pwsh-local/src/index.ts

@@ -0,0 +1,316 @@
+/**
+ * Local PowerShell implementation of the bash executor seam. Each command runs
+ * as `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` in a managed
+ * process spawned through `ctx.subprocess`; the executor owns command
+ * defaulting, deadlines and cause classification, the model-friendly terminal
+ * environment, and the model-facing stdout/stderr merge for background reads.
+ *
+ * The command string is passed as ONE argv element to `-Command`: PowerShell
+ * itself parses the text, and no intermediate shell exists, so there is no
+ * shell-quoting layer to escape (the `bash -c` string domain has no
+ * equivalent here). Native Win32 paths (`C:\...`) pass through unchanged.
+ *
+ * @module @deepseek-ai/dsh-pwsh-local
+ */
+
+import { existsSync } from 'node:fs'
+import { join } from 'node:path'
+import { Context } from 'cordis'
+import z from 'schemastery'
+import { BashExecutor } from '@deepseek-ai/dsh-bash'
+import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
+import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
+import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
+
+/**
+ * Model-friendly environment overrides for PowerShell: disable colors and
+ * pagers that would garble tool output. `TERM=dumb` is a POSIX concept and is
+ * deliberately absent; `NO_COLOR` is honored by modern pwsh renderers.
+ */
+export const ENV_OVERRIDES = {
+  NO_COLOR: '1',
+  PAGER: 'cat',
+  GIT_PAGER: 'cat',
+} as const
+
+/** Default SIGTERM→SIGKILL grace period (the `graceMs` config). */
+const DEFAULT_GRACE_MS = 3_000
+
+/** Default per-stream spill cap (the `maxSpillBytes` config). */
+const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024
+
+/** Plugin config (all optional — `static Config` supplies the defaults). */
+export interface Config {
+  /** Default working directory for commands (default: process.cwd()). */
+  cwd?: string
+  /** Default foreground timeout in milliseconds. */
+  timeoutMs?: number
+  /** Upper bound for per-call timeout overrides. */
+  maxTimeoutMs?: number
+  /** Per-stream in-memory output cap; overflow spills to a temp file. */
+  maxOutputBytes?: number
+  /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
+  maxSpillBytes?: number
+  /** Grace period for kill escalation and for inherited pipes after shell exit. */
+  graceMs?: number
+  /**
+   * Explicit pwsh executable. When omitted, well-known Windows install
+   * locations are probed first (PowerShell 7, then Windows PowerShell 5.1),
+   * falling back to a bare `pwsh` resolved through PATH.
+   */
+  pwshPath?: string
+}
+
+/** The shape after schemastery applied the defaults (cwd/pwshPath have none). */
+type ResolvedConfig = Required<Omit<Config, 'cwd' | 'pwshPath'>> & Pick<Config, 'cwd' | 'pwshPath'>
+
+/**
+ * Well-known Windows PowerShell install locations plus PATH entries, newest
+ * first. Explicitly parameterized (env) so resolution is a pure function of
+ * its inputs on every platform.
+ * @param env - the environment to probe; defaults to the process environment.
+ * @returns candidate `pwsh` executable paths in resolution order.
+ */
+export function candidatePwshPaths(env: NodeJS.ProcessEnv = process.env): string[] {
+  const programFiles = env.ProgramFiles ?? 'C:\\Program Files'
+  const systemRoot = env.SystemRoot ?? 'C:\\Windows'
+  const candidates = [
+    join(programFiles, 'PowerShell', '7', 'pwsh.exe'),
+  ]
+  // Microsoft Store installs (and any user-added location) live on PATH;
+  // entries may carry surrounding quotes from `setx`-style definitions.
+  for (const entry of (env.PATH ?? '').split(';')) {
+    const trimmed = entry.trim().replace(/^"|"$/g, '')
+    if (trimmed.length === 0) continue
+    candidates.push(join(trimmed, 'pwsh.exe'))
+  }
+  // Windows PowerShell 5.1 remains the last-resort fallback on legacy hosts.
+  candidates.push(join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'))
+  return candidates
+}
+
+/**
+ * Resolve the pwsh executable this executor spawns.
+ * @param configured - an explicit `pwshPath` config value, trusted as-is.
+ * @param env - the environment to probe on Windows; defaults to the process environment.
+ * @param platform - the platform to resolve for; defaults to the process platform.
+ * @returns the first existing well-known location on Windows (PowerShell 7
+ *   install, a PATH entry such as the Microsoft Store install, then Windows
+ *   PowerShell 5.1), else `pwsh` for PATH resolution.
+ */
+export function resolvePwshPath(
+  configured?: string,
+  env: NodeJS.ProcessEnv = process.env,
+  platform: NodeJS.Platform = process.platform,
+): string {
+  if (configured !== undefined && configured.length > 0) return configured
+  if (platform === 'win32') {
+    for (const candidate of candidatePwshPaths(env)) {
+      if (existsSync(candidate)) return candidate
+    }
+  }
+  return 'pwsh'
+}
+
+/** Project a settled collect-mode reader into the final CollectedOutput shape. */
+function finalOutput(reader: SubprocessOutputReader): CollectedOutput {
+  const read = reader.readFrom(0)
+  return {
+    text: read.text,
+    truncated: read.lossy,
+    ...read.spillPath !== undefined ? { spillPath: read.spillPath } : {},
+  }
+}
+
+function assertPositiveFinite(name: string, value: number): void {
+  if (!Number.isFinite(value) || value <= 0) {
+    throw new Error(`pwsh-local: ${name} must be a positive finite number`)
+  }
+}
+
+/**
+ * Local PowerShell executor over `ctx.subprocess`. Bounded output, spill
+ * files, and process-tree termination are the subprocess service's mechanics;
+ * this executor supplies their configured budgets per spawn.
+ */
+export class PwshLocalExecutor extends BashExecutor {
+  static inject = ['subprocess']
+
+  static Config: z<Config> = z.object({
+    cwd: z.string(),
+    timeoutMs: z.number().default(120_000),
+    maxTimeoutMs: z.number().default(600_000),
+    maxOutputBytes: z.number().default(64_000),
+    maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES),
+    graceMs: z.number().default(DEFAULT_GRACE_MS),
+    pwshPath: z.string(),
+  })
+
+  /** Validated config (schemastery applied the defaults before construction). */
+  readonly config: ResolvedConfig
+
+  /** The pwsh executable resolved once at construction. */
+  readonly pwshPath: string
+
+  constructor(ctx: Context, config: Config) {
+    super(ctx)
+    // Schemastery fills these fields before construction; the type does not encode that step.
+    this.config = config as ResolvedConfig
+    assertPositiveFinite('timeoutMs', this.config.timeoutMs)
+    assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs)
+    assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
+    assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
+    assertPositiveFinite('graceMs', this.config.graceMs)
+    this.pwshPath = resolvePwshPath(this.config.pwshPath)
+  }
+
+  /**
+   * Resolve a request into a fully-specified spec: fill `workdir` from
+   * `config.cwd` (else `process.cwd()`), and `timeoutMs` from
+   * `config.timeoutMs`, capped at `config.maxTimeoutMs`.
+   */
+  resolve(request: BashExecRequest): BashExecSpec {
+    const timeoutMs = clampTimeout(
+      request.timeoutMs,
+      this.config.timeoutMs,
+      this.config.maxTimeoutMs,
+      'pwsh-local: request.timeoutMs',
+    )
+    const stdoutMaxBytes = request.stdoutMaxBytes ?? this.config.maxOutputBytes
+    assertPositiveFinite('request.stdoutMaxBytes', stdoutMaxBytes)
+    return {
+      command: request.command,
+      workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
+      timeoutMs,
+      stdoutMaxBytes,
+      ...request.signal ? { signal: request.signal } : {},
+      ...request.stdin !== undefined ? { stdin: request.stdin } : {},
+      ...request.env !== undefined ? { env: request.env } : {},
+      ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
+      sandboxPolicy: request.sandboxPolicy,
+    }
+  }
+
+  /** Map one resolved bash spec onto a fully-specified subprocess spawn. */
+  private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec {
+    const collect = (maxBytes: number): SubprocessCollect =>
+      ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
+    return {
+      argv: [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', spec.command],
+      cwd: spec.workdir,
+      stdio: {
+        stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore',
+        stdout: collect(stdoutMaxBytes),
+        stderr: collect(this.config.maxOutputBytes),
+      },
+      graceMs: this.config.graceMs,
+      signal,
+      env: { ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv },
+    }
+  }
+
+  /** The collect-mode readers the executor itself requested (present by construction). */
+  private static collected(handle: SubprocessHandle): { stdout: SubprocessOutputReader; stderr: SubprocessOutputReader } {
+    const { stdout, stderr } = handle.collected
+    /* v8 ignore start -- collect dispositions expose both readers by the seam contract; defensive. */
+    if (stdout === undefined || stderr === undefined) {
+      throw new Error('pwsh-local: subprocess implementation dropped a requested collect stream')
+    }
+    /* v8 ignore stop */
+    return { stdout, stderr }
+  }
+
+  async run(spec: BashExecSpec): Promise<BashRunResult> {
+    // One deadline combines timeout and upstream cancellation; disposal clears its timer.
+    using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
+    const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal))
+    const outcome = await handle.done
+    const collected = PwshLocalExecutor.collected(handle)
+    // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
+    const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
+    const aborted = d.signal.aborted && !timedOut
+    return {
+      ...outcome,
+      timedOut,
+      aborted,
+      timeoutMs: spec.timeoutMs,
+      stdout: finalOutput(collected.stdout),
+      stderr: finalOutput(collected.stderr),
+    }
+  }
+
+  start(spec: BashExecSpec): BashProcess {
+    // Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
+    const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal))
+    const collected = PwshLocalExecutor.collected(running)
+
+    // A spawn failure produces no process output, so the subprocess service has nothing
+    // to buffer; the note is delivered exactly once through the read path.
+    let spawnFailureNote: string | undefined
+    const consumeSpawnFailure = (): string => {
+      const note = spawnFailureNote ?? ''
+      spawnFailureNote = undefined
+      return note
+    }
+
+    let stdoutOffset = 0
+    let stderrOffset = 0
+    const proc: BashProcess = {
+      status: 'running',
+      exitCode: null,
+      signal: null,
+      done: running.done.then((outcome) => {
+        // Any signal termination is killed, including a command signaling itself.
+        if (proc.status === 'running') {
+          proc.status = spec.signal?.aborted === true || outcome.signal !== null ? 'killed' : 'completed'
+        }
+        proc.exitCode = outcome.exitCode
+        proc.signal = outcome.signal
+        this.onProcessDone(proc, collected.stderr.readFrom(0).text)
+      }, (error: unknown) => {
+        // Background spawn failures settle as killed and surface through the read path.
+        proc.status = 'killed'
+        spawnFailureNote = `spawn failed: ${String(error)}`
+        this.onProcessDone(proc, spawnFailureNote)
+      }),
+      readOutput: (): BashProcessRead => {
+        const out = collected.stdout.readFrom(stdoutOffset)
+        const err = collected.stderr.readFrom(stderrOffset)
+        stdoutOffset = out.nextOffset
+        stderrOffset = err.nextOffset
+
+        // A failed spawn never produced process output, so the note and real
+        // stderr text are mutually exclusive.
+        const errText = err.text.length > 0 ? err.text : consumeSpawnFailure()
+        // Single newline between sections: stdout chunks usually end with one
+        // already; add it only when missing.
+        const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
+        const delta = out.text
+          + (errText.length > 0 ? `${separator}[stderr]\n${errText}` : '')
+        return {
+          delta,
+          lossy: out.lossy || err.lossy,
+          ...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {},
+          ...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {},
+        }
+      },
+      kill: (): boolean => {
+        if (proc.status !== 'running') return false
+        proc.status = 'killed'
+        running.terminate()
+        return true
+      },
+    }
+    return proc
+  }
+
+  /**
+   * Settlement hook for subclasses that attach execution facts to a process.
+   * The base implementation is intentionally empty.
+   * @param _proc - the settled process handle.
+   * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
+   */
+  protected onProcessDone(_proc: BashProcess, _stderr: string): void {}
+}
+
+export default PwshLocalExecutor

+ 30 - 0
packages/bash/pwsh-local/src/invariant.ts

@@ -0,0 +1,30 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-pwsh-local`.
+ * @module @deepseek-ai/dsh-pwsh-local/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-pwsh-local'
+
+/** Cordis companion plugin name. */
+export const name = 'pwsh-local-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: this package exposes no independent event sequence or mutable data relation
+ * beyond contracts enforced at its owning seam.
+ */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */

+ 412 - 0
packages/bash/pwsh-local/tests/executor.spec.ts

@@ -0,0 +1,412 @@
+/**
+ * Real-process tests for `@deepseek-ai/dsh-pwsh-local`: the LOCAL subprocess
+ * service plus a REAL pwsh executable, exercised through the executor seam
+ * (`resolve` → `run`/`start`). These verify the world — actual PowerShell
+ * runs, output capture, truncation and spill, deadlines, kill escalation, and
+ * the background-handle contract. The suite self-skips when no `pwsh` is on
+ * PATH (a CI accommodation for hosts without PowerShell); the pure unit tests
+ * (config validation, executable resolution) run on every platform. PowerShell
+ * writes CRLF on Windows, so exact text assertions normalize line endings.
+ */
+
+import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { spawnSync } from 'node:child_process'
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import { PwshLocalExecutor, candidatePwshPaths, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
+import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
+import type { BashProcess } from '@deepseek-ai/dsh-bash'
+
+const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-exec-spec-'))
+
+const hasPwsh = spawnSync('pwsh', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
+
+/** Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). */
+const lf = (text: string): string => text.replace(/\r\n/g, '\n')
+
+/** Case-insensitive path equality on Windows (Get-Location may re-case the drive). */
+function samePath(actual: string, expected: string): boolean {
+  const norm = (value: string) => (process.platform === 'win32' ? value.toLowerCase() : value)
+  return norm(actual) === norm(expected)
+}
+
+async function setup(config: ConstructorParameters<typeof PwshLocalExecutor>[1] = {}) {
+  const ctx = new Context()
+  await ctx.plugin(LocalSubprocessService)
+  ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
+  // A short kill grace via the REAL config path, so escalation tests stay fast.
+  await ctx.plugin(PwshLocalExecutor, { graceMs: 200, ...config })
+  const bash = ctx.bash as PwshLocalExecutor
+  return { ctx, bash }
+}
+
+/**
+ * Poll a handle's consuming readOutput until the ACCUMULATED delta contains
+ * `expected`; returns the accumulation (reads never re-deliver, so the caller
+ * gets everything produced up to the match).
+ */
+async function readUntil(proc: BashProcess, expected: string, timeoutMs = 5_000): Promise<string> {
+  const deadline = Date.now() + timeoutMs
+  let all = ''
+  while (Date.now() < deadline) {
+    all += proc.readOutput().delta
+    if (lf(all).includes(expected)) return lf(all)
+    await new Promise(resolve => setTimeout(resolve, 20))
+  }
+  throw new Error(`process output did not include ${JSON.stringify(expected)}; accumulated ${JSON.stringify(lf(all))}`)
+}
+
+describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => {
+  it('trusts an explicit configured path verbatim', () => {
+    expect(resolvePwshPath('C:\\custom\\pwsh.exe')).toBe('C:\\custom\\pwsh.exe')
+    expect(resolvePwshPath('pwsh')).toBe('pwsh')
+  })
+
+  it('falls through an empty configured path to platform resolution', () => {
+    // SystemRoot points at a non-existent tree so the Windows PowerShell 5.1
+    // fallback candidate cannot exist either.
+    expect(resolvePwshPath('', { PATH: 'P:\\Store', SystemRoot: 'S:\\no-windows' }, 'win32')).toBe('pwsh')
+  })
+
+  it('returns pwsh on non-Windows platforms regardless of the environment', () => {
+    expect(resolvePwshPath(undefined, { ProgramFiles: 'P:\\Program Files' }, 'linux')).toBe('pwsh')
+    expect(resolvePwshPath(undefined, { PATH: 'P:\\Store' }, 'darwin')).toBe('pwsh')
+  })
+
+  it('lists PowerShell 7, PATH entries (quotes stripped), then Windows PowerShell 5.1 on win32', () => {
+    const candidates = candidatePwshPaths({
+      ProgramFiles: 'P:\\Program Files',
+      SystemRoot: 'S:\\Windows',
+      PATH: ';"Q:\\quoted store";' + ';',
+    })
+    expect(candidates).toEqual([
+      join('P:\\Program Files', 'PowerShell', '7', 'pwsh.exe'),
+      join('Q:\\quoted store', 'pwsh.exe'),
+      join('S:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
+    ])
+    // A missing PATH contributes no entries (the empty-string fallback).
+    expect(candidatePwshPaths({ ProgramFiles: 'P:\\Program Files', SystemRoot: 'S:\\Windows' }))
+      .toEqual([
+        join('P:\\Program Files', 'PowerShell', '7', 'pwsh.exe'),
+        join('S:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
+      ])
+  })
+
+  it('returns the first EXISTING win32 candidate, else pwsh', () => {
+    const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-'))
+    const store = join(dir, 'store')
+    mkdirSync(store, { recursive: true })
+    writeFileSync(join(store, 'pwsh.exe'), '')
+    // The existing PATH entry wins over the non-existent Program Files install.
+    expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: store }, 'win32'))
+      .toBe(join(store, 'pwsh.exe'))
+    // No candidate exists anywhere (SystemRoot points at a non-existent tree,
+    // so even the Windows PowerShell 5.1 fallback cannot exist) → the
+    // PATH-resolution fallback.
+    expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: join(dir, 'empty'), SystemRoot: join(dir, 'no-windows') }, 'win32'))
+      .toBe('pwsh')
+  })
+})
+
+describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => {
+  it('resolves with output and the effective timeout', async () => {
+    const { bash } = await setup({ timeoutMs: 5_000 })
+    const result = await bash.run(bash.resolve({ command: 'Write-Output hi' }))
+    expect(result.exitCode).toBe(0)
+    expect(lf(result.stdout.text)).toBe('hi\n')
+    expect(result.timeoutMs).toBe(5_000)
+  })
+
+  it('uses config cwd, overridable per call', async () => {
+    const first = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-a-'))
+    const second = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-b-'))
+    const { bash } = await setup({ cwd: first })
+    const fromConfig = await bash.run(bash.resolve({ command: '(Get-Location).Path' }))
+    expect(samePath(fromConfig.stdout.text.trim(), first)).toBe(true)
+    const fromCall = await bash.run(bash.resolve({ command: '(Get-Location).Path', workdir: second }))
+    expect(samePath(fromCall.stdout.text.trim(), second)).toBe(true)
+  })
+
+  it('defaults cwd to process.cwd()', async () => {
+    const { bash } = await setup()
+    const result = await bash.run(bash.resolve({ command: '(Get-Location).Path' }))
+    expect(samePath(result.stdout.text.trim(), process.cwd())).toBe(true)
+  })
+
+  it('caps per-call timeouts at maxTimeoutMs', async () => {
+    const { bash } = await setup({ timeoutMs: 1_000, maxTimeoutMs: 2_000 })
+    const result = await bash.run(bash.resolve({ command: 'Write-Output ok', timeoutMs: 99_999 }))
+    expect(result.timeoutMs).toBe(2_000)
+  })
+
+  it('rejects invalid numeric config and timeout overrides', async () => {
+    await expect(setup({ timeoutMs: Number.NaN })).rejects.toThrow(/timeoutMs/)
+    await expect(setup({ maxTimeoutMs: 0 })).rejects.toThrow(/maxTimeoutMs/)
+    await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/)
+    await expect(setup({ maxSpillBytes: 0 })).rejects.toThrow(/maxSpillBytes/)
+    await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/)
+
+    const { bash } = await setup()
+    expect(() => bash.resolve({ command: 'Write-Output ok', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/)
+    expect(() => bash.resolve({ command: 'Write-Output ok', timeoutMs: -1 })).toThrow(/request\.timeoutMs/)
+    expect(() => bash.resolve({ command: 'Write-Output ok', stdoutMaxBytes: Number.NaN })).toThrow(/request\.stdoutMaxBytes/)
+    expect(() => bash.resolve({ command: 'Write-Output ok', stdoutMaxBytes: -1 })).toThrow(/request\.stdoutMaxBytes/)
+  })
+
+  it('defaults stdoutMaxBytes to maxOutputBytes and lets foreground callers raise stdout only', async () => {
+    const { bash } = await setup({ maxOutputBytes: 100 })
+    expect(bash.resolve({ command: 'Write-Output ok' }).stdoutMaxBytes).toBe(100)
+
+    // Raw Console writes avoid PowerShell's own line-ending and formatting
+    // layers, so the byte counts are exact on every platform.
+    const result = await bash.run(bash.resolve({
+      command: '[Console]::Out.Write("x" * 500); [Console]::Error.WriteLine("e" * 500)',
+      stdoutMaxBytes: 500,
+    }))
+
+    expect(result.stdout.text).toBe('x'.repeat(500))
+    expect(result.stdout.truncated).toBe(false)
+    expect(result.stderr.truncated).toBe(true)
+    expect(result.stderr.text.length).toBeLessThanOrEqual(100)
+  })
+
+  it('per-call timeout takes precedence under the cap and kills on expiry', async () => {
+    const { bash } = await setup({ timeoutMs: 60_000 })
+    const result = await bash.run(bash.resolve({ command: 'Start-Sleep -Seconds 60', timeoutMs: 100 }))
+    expect(result.timedOut).toBe(true)
+    // Mutually exclusive: a timeout classifies as timedOut, never also aborted.
+    expect(result.aborted).toBe(false)
+    expect(result.timeoutMs).toBe(100)
+  })
+
+  it('propagates abort signals', async () => {
+    const { bash } = await setup()
+    const controller = new AbortController()
+    const pending = bash.run(bash.resolve({ command: 'Start-Sleep -Seconds 60', signal: controller.signal }))
+    setTimeout(() => { controller.abort() }, 50)
+    const result = await pending
+    expect(result.aborted).toBe(true)
+    // Mutually exclusive: an upstream cancel classifies as aborted, never also timedOut.
+    expect(result.timedOut).toBe(false)
+  })
+
+  it('classifies a self-killed command as neither timed out nor aborted', async () => {
+    const { bash } = await setup({ timeoutMs: 60_000 })
+    const result = await bash.run(bash.resolve({ command: 'Stop-Process -Id $PID' }))
+    expect(result.timedOut).toBe(false)
+    expect(result.aborted).toBe(false)
+    // Windows reports a forced termination without a signal; POSIX reports SIGTERM.
+    if (process.platform === 'win32') {
+      expect(result.signal).toBeNull()
+    } else {
+      expect(result.signal).toBe('SIGTERM')
+    }
+  })
+
+  it('rejects on spawn failure (bad workdir)', async () => {
+    const { bash } = await setup()
+    await expect(bash.run(bash.resolve({ command: 'Write-Output ok', workdir: '/nonexistent-dsh' }))).rejects.toThrow(/ENOENT/)
+  })
+
+  it('resolve() carries stdin/env/dshEnv onto the spec, and run() threads them to the command', async () => {
+    const { bash } = await setup()
+    const spec = bash.resolve({
+      command: '$s = ([Console]::In.ReadToEnd()).TrimEnd(); Write-Output $s; Write-Output "[$env:SEAM_VAR][$env:DSH_SEAM_VAR]"',
+      stdin: 'piped\n',
+      env: { SEAM_VAR: 'env-ok' },
+      dshEnv: { DSH_SEAM_VAR: 'dsh-ok' },
+    })
+    // resolve() keeps the optional input/environment fields verbatim.
+    expect(spec.stdin).toBe('piped\n')
+    expect(spec.env).toEqual({ SEAM_VAR: 'env-ok' })
+    expect(spec.dshEnv).toEqual({ DSH_SEAM_VAR: 'dsh-ok' })
+    const result = await bash.run(spec)
+    expect(lf(result.stdout.text)).toBe('piped\n[env-ok][dsh-ok]\n')
+  })
+
+  it('resolve() omits stdin/env/dshEnv when the request supplies none', async () => {
+    const { bash } = await setup()
+    const spec = bash.resolve({ command: 'Write-Output ok' })
+    expect('stdin' in spec).toBe(false)
+    expect('env' in spec).toBe(false)
+    expect('dshEnv' in spec).toBe(false)
+  })
+})
+
+describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)', () => {
+  it('start returns immediately with a running handle that settles as completed', async () => {
+    const { bash } = await setup()
+    const before = Date.now()
+    const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Milliseconds 200; Write-Output done' }))
+    expect(Date.now() - before).toBeLessThan(150)
+    expect(proc.status).toBe('running')
+    await proc.done
+    expect(proc.status).toBe('completed')
+    expect(proc.exitCode).toBe(0)
+  })
+
+  it('threads stdin and extra env into a background process', async () => {
+    const { bash } = await setup()
+    const proc = bash.start(bash.resolve({
+      command: '$s = ([Console]::In.ReadToEnd()).TrimEnd(); Write-Output $s; Write-Output "[$env:BG_VAR][$env:DSH_BG_VAR]"',
+      stdin: 'bg-stdin\n',
+      env: { BG_VAR: 'bg-env' },
+      dshEnv: { DSH_BG_VAR: 'bg-dsh-env' },
+    }))
+    const output = await readUntil(proc, '[bg-env][bg-dsh-env]')
+    expect(output).toBe('bg-stdin\n[bg-env][bg-dsh-env]\n')
+    await proc.done
+    expect(proc.exitCode).toBe(0)
+  })
+
+  it('readOutput is consuming: increments are never re-delivered, and reads stay valid after exit', async () => {
+    const { bash } = await setup()
+    const proc = bash.start(bash.resolve({ command: 'Write-Output first; Start-Sleep -Seconds 1; Write-Output second' }))
+    const first = await readUntil(proc, 'first\n')
+    expect(lf(first)).toBe('first\n')
+    await proc.done
+    // Read-after-exit returns the remaining buffered output — once.
+    const second = proc.readOutput()
+    expect(lf(second.delta)).toBe('second\n')
+    expect(second.lossy).toBe(false)
+    expect(proc.readOutput().delta).toBe('')
+  })
+
+  it('readOutput marks stderr sections', async () => {
+    const { bash } = await setup()
+    const proc = bash.start(bash.resolve({ command: 'Write-Output out; [Console]::Error.WriteLine("err")' }))
+    await proc.done
+    expect(lf(proc.readOutput().delta)).toBe('out\n[stderr]\nerr\n')
+  })
+
+  it('readOutput reports stderr-only deltas without a leading newline', async () => {
+    const { bash } = await setup()
+    const proc = bash.start(bash.resolve({ command: '[Console]::Error.WriteLine("err")' }))
+    await proc.done
+    expect(lf(proc.readOutput().delta)).toBe('[stderr]\nerr\n')
+  })
+
+  it('readOutput adds a separator only when stdout lacks a trailing newline', async () => {
+    const { bash } = await setup()
+    const proc = bash.start(bash.resolve({ command: '[Console]::Out.Write("out"); [Console]::Error.WriteLine("err")' }))
+    await proc.done
+    expect(lf(proc.readOutput().delta)).toBe('out\n[stderr]\nerr\n')
+  })
+
+  it('readOutput flags lossy reads and reports stdout spill paths', async () => {
+    const { bash } = await setup({ maxOutputBytes: 100 })
+    const proc = bash.start(bash.resolve({ command: '1..100 | ForEach-Object { "line-$_" }' }))
+    await proc.done
+    const read = proc.readOutput()
+    // Window slid past offset 0 → lossy, spill path points at the full stream.
+    expect(read.lossy).toBe(true)
+    expect(read.stdoutSpillPath).toBeDefined()
+  })
+
+  it('readOutput reports stderr spill paths', async () => {
+    const { bash } = await setup({ maxOutputBytes: 100 })
+    const proc = bash.start(bash.resolve({ command: '1..100 | ForEach-Object { [Console]::Error.WriteLine("line-$_") }' }))
+    await proc.done
+    const read = proc.readOutput()
+    expect(read.lossy).toBe(true)
+    expect(read.stderrSpillPath).toBeDefined()
+    expect(lf(read.delta)).toContain('[stderr]')
+  })
+
+  it('kill() terminates the process tree: true once, false after settlement', async () => {
+    const { bash } = await setup()
+    const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60' }))
+    expect(proc.kill()).toBe(true)
+    await proc.done
+    expect(proc.status).toBe('killed')
+    expect(proc.kill()).toBe(false)
+  })
+
+  it('kill() returns false for a naturally completed process', async () => {
+    const { bash } = await setup()
+    const proc = bash.start(bash.resolve({ command: 'Write-Output ok' }))
+    await proc.done
+    expect(proc.status).toBe('completed')
+    expect(proc.kill()).toBe(false)
+  })
+
+  it('a spec.signal abort settles the handle as killed, not completed', async () => {
+    const { bash } = await setup()
+    const controller = new AbortController()
+    const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60', signal: controller.signal }))
+    controller.abort()
+    await proc.done
+    expect(proc.status).toBe('killed')
+  })
+
+  it.skipIf(process.platform === 'win32')('a self-signal exit settles the handle as killed, not completed (POSIX)', async () => {
+    const { bash } = await setup()
+    const proc = bash.start(bash.resolve({ command: 'Stop-Process -Id $PID' }))
+    await proc.done
+    expect(proc.status).toBe('killed')
+    expect(proc.exitCode).toBeNull()
+    expect(proc.signal).toBe('SIGTERM')
+  })
+
+  it('a background spawn failure settles as killed with the error readable on stderr', async () => {
+    const { bash } = await setup()
+    const proc = bash.start(bash.resolve({ command: 'Write-Output ok', workdir: '/nonexistent-dsh' }))
+    // done resolves (never rejects) even though the process never ran.
+    await expect(proc.done).resolves.toBeUndefined()
+    expect(proc.status).toBe('killed')
+    expect(proc.readOutput().delta).toContain('spawn failed:')
+  })
+})
+
+describe.skipIf(!hasPwsh)('process lifecycle ownership (the subprocess service, not the executor)', () => {
+  it('a background process survives executor-fiber disposal and dies with the subprocess service', async () => {
+    const ctx = new Context()
+    const managerFiber = await ctx.plugin(LocalSubprocessService)
+    ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
+    const executorFiber = await ctx.plugin(PwshLocalExecutor, { graceMs: 200 })
+    const bash = ctx.bash as PwshLocalExecutor
+
+    // The child prints its own pid so the test can probe liveness through the
+    // public read surface alone.
+    const proc = bash.start(bash.resolve({ command: 'Write-Output $PID; Start-Sleep -Seconds 60' }))
+    const pid = Number((await readUntil(proc, '\n')).trim())
+    expect(Number.isInteger(pid) && pid > 0).toBe(true)
+
+    // Executor reload/disposal leaves background work running — the
+    // handle stays live and readable, mirroring the task runtime's
+    // registrations-outlive-producer-fibers contract.
+    await executorFiber.dispose()
+    expect(proc.status).toBe('running')
+    expect(() => process.kill(pid, 0)).not.toThrow()
+
+    // Service disposal kills the group and AWAITS its exit (no orphans).
+    await managerFiber.dispose()
+    expect(() => process.kill(pid, 0)).toThrow()
+    await proc.done
+    // POSIX reports the kill as a signal; Windows reports a forced
+    // termination as exit 1 with no signal (indistinguishable from a crash),
+    // so the status stamp follows the platform's exit facts.
+    expect(proc.status).toBe(process.platform === 'win32' ? 'completed' : 'killed')
+  })
+
+  it('service disposal settles running handles and leaves settled ones untouched', async () => {
+    const ctx = new Context()
+    const managerFiber = await ctx.plugin(LocalSubprocessService)
+    ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
+    await ctx.plugin(PwshLocalExecutor, { graceMs: 200 })
+    const bash = ctx.bash as PwshLocalExecutor
+
+    const finished = bash.start(bash.resolve({ command: 'Write-Output done' }))
+    await finished.done
+    expect(finished.status).toBe('completed')
+    const running = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60' }))
+
+    await managerFiber.dispose()
+    // A settled process was untouched; the live one was terminated and joined.
+    expect(finished.status).toBe('completed')
+    await running.done
+    expect(running.status).toBe(process.platform === 'win32' ? 'completed' : 'killed')
+  })
+})

+ 36 - 0
packages/bash/pwsh-local/tsconfig.json

@@ -0,0 +1,36 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cosmokit"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../../util/brand"
+    },
+    {
+      "path": "../../util/timeout"
+    },
+    {
+      "path": "../../bash/bash"
+    },
+    {
+      "path": "../../subprocess/subprocess"
+    },
+    {
+      "path": "../../support/invariants"
+    }
+  ]
+}

+ 6 - 0
packages/bash/tool-pwsh/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write packages/bash/tool-pwsh/README.md
+README.md: 4f1d62dbf49fef678e3285776c466286535d66da
+README.zh.md: bbeece3c648d8b1903eed1a66d2e14774c7ace8c

+ 107 - 0
packages/bash/tool-pwsh/README.md

@@ -0,0 +1,107 @@
+# @deepseek-ai/dsh-tool-pwsh
+
+English | [中文](README.zh.md)
+
+The model-facing `pwsh` tool registered over the `ctx.bash` executor seam. Intended for Windows compositions where a PowerShell executor (e.g. `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. Minimal by design — no background tasks, no sandbox escalation, no persistent shell: this is the "works on my Windows machine" profile until the full bash-tool feature set gets a PowerShell twin.
+
+Requires a loaded executor implementation; the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`).
+
+The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`) plus the pure `renderPwshOutput` helper and its result type; execution and presentation remain implementation details covered by same-package tests.
+
+The plugin also contributes the `tool:pwsh` prompt section (order 105): check the `[exit code: N]` marker on every result and investigate failures before moving on.
+
+## Tools
+
+### `pwsh`
+
+| Arg | Type | Notes |
+|---|---|---|
+| `command` | string (required) | Run via `pwsh -Command`. No state persists between calls — use `workdir`, not `cd`. |
+| `description` | string (required) | One-line, active-voice summary of the command (5-10 words), for UI/log display only — no effect on execution. |
+| `timeoutMs` | number | Timeout override in milliseconds. The executor applies its configured default and cap. |
+| `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that same identity. |
+
+`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution. The workdir default is applied in the tool layer from the calling agent's `session.header.cwd` BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`.
+
+### Managed shell environment
+
+Every call receives a freshly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; `process.env` is never modified.
+
+Result text contains stdout, an optional `[stderr]` section, then applicable timeout, signal, and exit-code markers: `[timed out after <timeoutMs>ms]`, `[killed by signal: <signal>]`, and `[exit code: N]`, each separated by a newline only when the accumulated text lacks one. Nonzero exit remains a model-interpreted result rather than `isError`. Only infrastructure failures — spawn errors and aborts (`tool call aborted`) — produce `isError`.
+
+The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process. Programmatic consumers use the typed fields without parsing the rendered text.
+
+## UI presentation
+
+The tool owns its `presentCall`/`presentResult` render intent. A call is a `terminal` card carrying command, description, and optional cwd; a completed result is a `generic` card with the rendered output in a `console` fence. These presenters are pure and replay-safe.
+
+## Model Experience
+
+### System prompt
+
+#### What the model sees
+
+Every request in this plugin's registration scope contains the pwsh guidance below. Scoped tool restrictions can hide the schema without removing this independently registered section.
+
+##### Pwsh guidance
+
+```markdown
+Check the [exit code: N] marker on every pwsh result; investigate failures before moving on.
+```
+
+#### Token effect
+
+Small fixed input cost per request while the plugin is active.
+
+#### KV Cache effect
+
+Prefix-stable while the registration scope and prompt text are unchanged. Plugin activation or disposal may invalidate reuse from this prompt section.
+
+### Tool schemas
+
+#### What the model sees
+
+The model sees the generated [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh). Agent-scoped tool restrictions can remove the definition for that agent.
+
+#### Token effect
+
+Fixed schema cost on every request where the tool is visible.
+
+#### KV Cache effect
+
+Prefix-stable while visibility and the tool definition are unchanged. A restriction or config change may invalidate reuse from the first changed token.
+
+### Foreground result
+
+#### What the model sees
+
+The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. Conditional lines are exactly `[timed out after <timeoutMs>ms]`, `[killed by signal: <signal>]`, and `[exit code: <exitCode>]`.
+
+#### Token effect
+
+Zero result tokens before a call. Output is bounded per stream, while each emitted line remains in history until compaction.
+
+#### KV Cache effect
+
+Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
+
+### Tool errors
+
+#### What the model sees
+
+Validation and infrastructure failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, and `tool call aborted`.
+
+#### Token effect
+
+Only the failing call adds these retained tokens; an aborted call adds no command output.
+
+#### KV Cache effect
+
+Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
+
+## Known Limitations and Deferred Work
+
+- **Foreground-only** — no `run_in_background`; long-running work must stay within the executor timeout or wait for the bash-tool twin.
+- **No sandbox escalation** — `sandbox_permissions`/`justification` are absent; a confining composition denies through the executor, and escalation waits for the full twin.
+- **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation.
+- **Windows-default roadmap deferred** — defaulting Windows hosts to `pwsh` over `bash`, and pwsh TUI/GUI rendering support, are planned separately and deliberately not part of this package yet.

+ 107 - 0
packages/bash/tool-pwsh/README.zh.md

@@ -0,0 +1,107 @@
+# @deepseek-ai/dsh-tool-pwsh
+
+[English](README.md) | 中文
+
+面向模型的 `pwsh` 工具,注册在 `ctx.bash` 执行器 seam 之上。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具契约是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。刻意保持最小——无后台任务、无沙箱升级、无持久 shell:在完整 bash 工具功能集获得 PowerShell 孪生之前,这就是 "works on my Windows machine" 画像。
+
+需要一个已加载的执行器实现;插件在 `ctx.bash` 存在之前保持 pending(`inject: ['tools', 'bash', 'systemPrompt']`)。
+
+包根只暴露 Cordis 插件契约(`name`、`inject`、`Config`、`apply`)以及纯函数 `renderPwshOutput` 及其结果类型;执行与呈现是同一包测试覆盖的实现细节。
+
+该插件还贡献 `tool:pwsh` 提示词段(order 105):检查每个结果上的 `[exit code: N]` 标记,并在继续前调查失败。
+
+## 工具
+
+### `pwsh`
+
+| 参数 | 类型 | 说明 |
+|---|---|---|
+| `command` | string(必填) | 通过 `pwsh -Command` 运行。调用之间不保留状态——用 `workdir`,不要用 `cd`。 |
+| `description` | string(必填) | 命令的一句话主动语态摘要(5-10 词),仅用于 UI/日志展示——不影响执行。 |
+| `timeoutMs` | number | 毫秒级超时覆盖。执行器应用其配置的默认值与上限。 |
+| `workdir` | string | 本次调用的工作目录。默认取调用 agent(智能体)的会话 cwd(`session.header.cwd`),使每个会话在自己的工作区运行;相对 `workdir` 基于同一身份解析。 |
+
+`command`、`workdir` 与 `timeoutMs` 在执行前经 `ctx.bash.resolve()` 按执行器配置默认值解析。workdir 默认值在工具层取自调用 agent 的 `session.header.cwd`,先于 `resolve()` 应用——每个会话的 cwd 必须来自 `exec.agent`,因为 N 个会话共享一个执行器;只有没有会话 cwd 时,执行器才回退到自己的配置 / `process.cwd()`。
+
+### 受管 shell 环境
+
+每次调用都会收到一份新收集的受信 `DSH_*` 环境。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 Harness 绝对主目录(`dshHome` 配置,其次环境变量 `$DSH_HOME`,再其次 `~/.dsh`),`DSH_SHELL=1` 标识受管子进程。agent 调用额外收到 `DSH_SESSION_ID=agent.session.header.id`。该快照经由专用 `BashExecRequest.dshEnv` 通道传递;`process.env` 永不被修改。
+
+结果文本包含 stdout、可选的 `[stderr]` 分段,以及适用的超时、信号与退出码标记:`[timed out after <timeoutMs>ms]`、`[killed by signal: <signal>]` 与 `[exit code: N]`,仅在累积文本缺少换行时才补一个分隔换行。非零退出仍是模型自行解读的结果,而不是 `isError`。只有基础设施失败——spawn 错误与中止(`tool call aborted`)——才产生 `isError`。
+
+规范成功值为已完成前台进程的 `{ kind: 'foreground', ...BashRunResult }`。程序化消费方使用类型化字段,而不解析渲染文本。
+
+## UI 呈现
+
+工具拥有自己的 `presentCall`/`presentResult` 渲染意图。调用是携带命令、描述与可选 cwd 的 `terminal` 卡片;完成结果是 `generic` 卡片,渲染输出放在 `console` 围栏内。这些 presenter 是纯函数且可重放。
+
+## 模型体验
+
+### 系统提示词
+
+#### 模型看到的内容
+
+该插件注册作用域内的每个请求都包含下方 pwsh 指导。作用域工具限制可以隐藏 schema,而不移除这个独立注册的提示词段。
+
+##### Pwsh 指导
+
+```markdown
+Check the [exit code: N] marker on every pwsh result; investigate failures before moving on.
+```
+
+#### Token 影响
+
+插件激活期间每个请求有少量固定输入成本。
+
+#### KV Cache 影响
+
+注册作用域与提示词文本不变时前缀稳定。插件激活或销毁可能使该提示词段的复用失效。
+
+### 工具 schema
+
+#### 模型看到的内容
+
+模型看到生成的 [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh)。agent 作用域的工具限制可以为该 agent 移除定义。
+
+#### Token 影响
+
+工具可见时每个请求有固定的 schema 成本。
+
+#### KV Cache 影响
+
+可见性与工具定义不变时前缀稳定。限制或配置变更可能从第一个改变的 token 起使复用失效。
+
+### 前台结果
+
+#### 模型看到的内容
+
+渲染器输出依赖数据的 stdout 尾部,然后是可选 `[stderr]` 与 stderr 尾部。条件行恰为 `[timed out after <timeoutMs>ms]`、`[killed by signal: <signal>]` 与 `[exit code: <exitCode>]`。
+
+#### Token 影响
+
+调用前零结果 token。输出按流有界,每条已发出行在压缩前保留在历史中。
+
+#### KV Cache 影响
+
+只追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV-cache 条目失效。
+
+### 工具错误
+
+#### 模型看到的内容
+
+校验与基础设施失败被规范化为 `Error: <message>`。本包的稳定消息为 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got <value>` 与 `tool call aborted`。
+
+#### Token 影响
+
+只有失败的调用会增加这些保留 token;中止的调用不增加命令输出。
+
+#### KV Cache 影响
+
+只追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV-cache 条目失效。
+
+## 已知局限与延期工作
+
+- **仅前台**——没有 `run_in_background`;长时间运行的工作必须留在执行器超时之内,或等待 bash 工具孪生。
+- **无沙箱升级**——没有 `sandbox_permissions`/`justification`;受约束的组合通过执行器拒绝,升级等待完整孪生。
+- **PowerShell 方言契约**——模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。
+- **Windows 默认路线图延期**——让 Windows 主机默认用 `pwsh` 而非 `bash`,以及 pwsh TUI/GUI 渲染支持,都另行规划,刻意不纳入本包。

+ 56 - 0
packages/bash/tool-pwsh/package.json

@@ -0,0 +1,56 @@
+{
+  "name": "@deepseek-ai/dsh-tool-pwsh",
+  "description": "Model-facing pwsh tool over the bash executor seam",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./invariant": {
+      "types": "./lib/types/invariant.d.ts",
+      "default": "./lib/invariant.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-agent": "^0.0.1",
+    "@deepseek-ai/dsh-bash": "^0.0.1",
+    "@deepseek-ai/dsh-invariants": "^0.0.1",
+    "@deepseek-ai/dsh-llm": "^0.0.1",
+    "@deepseek-ai/dsh-paths": "^0.0.1",
+    "@deepseek-ai/dsh-session-persistence": "^0.0.1",
+    "@deepseek-ai/dsh-system-prompt": "^0.0.1",
+    "@deepseek-ai/dsh-tools": "^0.0.1",
+    "cordis": "^4.0.0-rc.7"
+  },
+  "dependencies": {
+    "schemastery": "^3.18.0"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-bash": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-paths": "workspace:^",
+    "@deepseek-ai/dsh-pwsh-local": "workspace:^",
+    "@deepseek-ai/dsh-session-persistence": "workspace:^",
+    "@deepseek-ai/dsh-subprocess-local": "workspace:^",
+    "@deepseek-ai/dsh-system-prompt": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "cordis": "^4.0.0-rc.7"
+  }
+}

+ 254 - 0
packages/bash/tool-pwsh/src/index.ts

@@ -0,0 +1,254 @@
+/**
+ * Model-facing `pwsh` tool over the `ctx.bash` executor seam. Intended for
+ * Windows compositions where a PowerShell executor (e.g.
+ * `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is
+ * PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables.
+ *
+ * Minimal by design: no background tasks, no sandbox escalation — this is the
+ * "works on my Windows machine" profile until the full bash-tool feature set
+ * gets a PowerShell twin.
+ *
+ * @module @deepseek-ai/dsh-tool-pwsh
+ */
+
+import { isAbsolute, resolve as resolvePath } from 'node:path'
+import { Context } from 'cordis'
+import z from 'schemastery'
+import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
+import type { TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
+import { HarnessError } from '@deepseek-ai/dsh-llm'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import type {} from '@deepseek-ai/dsh-session-persistence'
+import type {} from '@deepseek-ai/dsh-system-prompt'
+import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
+import type { BashRunResult, DshEnvironment } from '@deepseek-ai/dsh-bash'
+import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths'
+
+export const name = 'tool-pwsh'
+export const inject = ['tools', 'bash', 'systemPrompt']
+
+/** Plugin config (currently empty; kept as a schema so deployments can grow it). */
+export interface Config {
+  /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */
+  dshHome?: string
+}
+
+/** Runtime configuration schema for the pwsh tool plugin. */
+export const Config: z<Config> = z.object({
+  dshHome: z.string(),
+})
+
+/** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */
+interface PwshToolArgs {
+  command: string
+  description: string
+  timeoutMs?: number
+  workdir?: string
+}
+
+/** The canonical foreground result of one pwsh call (the `output.schema` value shape). */
+interface PwshForegroundResult {
+  kind: 'foreground'
+  exitCode: number | null
+  signal: NodeJS.Signals | null
+  timedOut: boolean
+  aborted: boolean
+  timeoutMs: number
+  stdout: { text: string; truncated: boolean; spillPath?: string }
+  stderr: { text: string; truncated: boolean; spillPath?: string }
+}
+
+function validatePwshArgs(args: PwshToolArgs): void {
+  if (args.command.trim().length === 0) {
+    throw new Error('invalid command: expected a non-empty string')
+  }
+  if (args.description.trim().length === 0) {
+    throw new Error('invalid description: expected a non-empty string')
+  }
+  if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
+    throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`)
+  }
+}
+
+function pwshDescription(): string {
+  return 'Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. '
+    + 'Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — '
+    + 'pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\\...`); read environment '
+    + 'variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. '
+    + 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available.'
+}
+
+/**
+ * Resolve an explicit workdir first, making a relative one session-workspace-relative;
+ * otherwise use the session header cwd and leave executor defaulting as the fallback.
+ */
+function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent }): string | undefined {
+  const headerCwd = exec.agent?.session.header.cwd
+  if (modelWorkdir === undefined) return headerCwd
+  if (headerCwd !== undefined && !isAbsolute(modelWorkdir)) {
+    return resolvePath(headerCwd, modelWorkdir)
+  }
+  return modelWorkdir
+}
+
+/**
+ * The model-facing text of one foreground pwsh result: stdout, a marked
+ * stderr section, then the applicable timeout, signal, and exit markers —
+ * each separated by a newline only when the accumulated text lacks one, so a
+ * trailing newline in stdout never produces a blank line.
+ *
+ * @param value - the canonical foreground result (the schema-derived value shape).
+ * @returns the model-facing text.
+ */
+function renderPwshOutput(value: RenderablePwshOutput): string {
+  let rendered = value.stdout.text
+  const marker = (line: string): void => {
+    rendered += rendered.length > 0 && !rendered.endsWith('\n') ? `\n${line}` : line
+  }
+  if (value.stderr.text.length > 0) marker(`[stderr]\n${value.stderr.text}`)
+  if (value.timedOut) marker(`[timed out after ${value.timeoutMs}ms]`)
+  if (value.signal !== null) marker(`[killed by signal: ${value.signal}]`)
+  if (value.exitCode !== null) marker(`[exit code: ${value.exitCode}]`)
+  return rendered
+}
+
+/**
+ * Detach the executor DTO from readonly seam interfaces into plain JSON data.
+ * @param result - the executor's run outcome.
+ * @returns the canonical foreground result the tool returns and renders.
+ */
+function canonicalPwshResult(result: BashRunResult): PwshForegroundResult {
+  const output = (stream: BashRunResult['stdout']) => ({
+    text: stream.text,
+    truncated: stream.truncated,
+    ...stream.spillPath !== undefined ? { spillPath: stream.spillPath } : {},
+  })
+  return {
+    kind: 'foreground',
+    exitCode: result.exitCode,
+    signal: result.signal,
+    timedOut: result.timedOut,
+    aborted: result.aborted,
+    timeoutMs: result.timeoutMs,
+    stdout: output(result.stdout),
+    stderr: output(result.stderr),
+  }
+}
+
+/** The rendered fields of a foreground result — the schema-derived value shape (no `kind`, plain-string signal). */
+interface RenderablePwshOutput {
+  exitCode: number | null
+  signal: string | null
+  timedOut: boolean
+  timeoutMs: number
+  stdout: { text: string }
+  stderr: { text: string }
+}
+
+/**
+ * The managed `DSH_*` snapshot for one pwsh call: the harness home, a shell
+ * marker, and the session identity when an agent is present.
+ */
+function collectDshEnv(exec: ToolExecution, dshHome: string): DshEnvironment {
+  const values: Record<string, string> = {
+    [DSH_HOME_ENV]: dshHome,
+    [`${DSH_ENV_PREFIX}SHELL`]: '1',
+  }
+  if (exec.agent !== undefined) {
+    values[`${DSH_ENV_PREFIX}SESSION_ID`] = exec.agent.session.header.id
+  }
+  return values
+}
+
+export function apply(ctx: Context, config: Config = {}): void {
+  const dshHome = resolveDshHome(config.dshHome)
+
+  ctx.systemPrompt.section({
+    name: 'tool:pwsh',
+    order: 105,
+    text: 'Check the [exit code: N] marker on every pwsh result; investigate failures before moving on.',
+  })
+
+  ctx.tools.register(defineTool({
+    name: 'pwsh',
+    description: pwshDescription(),
+    parameters: {
+      command: { type: 'string', required: true, description: 'The PowerShell command to execute.' },
+      description: {
+        type: 'string',
+        required: true,
+        description: 'Clear, concise description of what this command does in active voice, '
+          + '5-10 words (shown in the UI). Examples: "ls" → "List files in current directory"; '
+          + '"git status" → "Show working tree status"; "Get-Process" → "List running processes".',
+      },
+      timeoutMs: { type: 'number', description: 'Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry.' },
+      workdir: { type: 'string', description: 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it.' },
+    },
+    output: {
+      schema: {
+        type: 'object',
+        additionalProperties: false,
+        properties: {
+          kind: { type: 'string', required: true, const: 'foreground' },
+          exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] },
+          signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] },
+          timedOut: { type: 'boolean', required: true },
+          aborted: { type: 'boolean', required: true },
+          timeoutMs: { type: 'number', required: true },
+          stdout: {
+            type: 'object',
+            additionalProperties: false,
+            required: true,
+            properties: {
+              text: { type: 'string', required: true },
+              truncated: { type: 'boolean', required: true },
+              spillPath: { type: 'string' },
+            },
+          },
+          stderr: {
+            type: 'object',
+            additionalProperties: false,
+            required: true,
+            properties: {
+              text: { type: 'string', required: true },
+              truncated: { type: 'boolean', required: true },
+              spillPath: { type: 'string' },
+            },
+          },
+        },
+      },
+      render: (_args, value) => [{
+        type: 'text',
+        text: renderPwshOutput(value),
+      }],
+    },
+    async execute(args: PwshToolArgs, exec) {
+      validatePwshArgs(args)
+      const workdir = resolveWorkdir(args.workdir, exec)
+      const result = await ctx.bash.run(ctx.bash.resolve({
+        command: args.command,
+        ...workdir !== undefined ? { workdir } : {},
+        ...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
+        dshEnv: collectDshEnv(exec, dshHome),
+        signal: exec.signal,
+      }))
+      if (result.aborted) {
+        const error = new HarnessError('tool call aborted', TOOL_ABORTED)
+        error.name = 'AbortError'
+        throw error
+      }
+      return canonicalPwshResult(result)
+    },
+    presentCall: (args: PwshToolArgs): TerminalCallView => ({
+      card: 'terminal',
+      title: args.command,
+      description: args.description,
+      ...args.workdir !== undefined ? { cwd: args.workdir } : {},
+    }),
+    presentResult: (_args: unknown, result: ToolResult): ToolResultView | undefined => {
+      const block = result.content.length === 1 ? result.content[0] : undefined
+      if (block === undefined || block.type !== 'text') return undefined
+      return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${block.text.replace(/\n+$/, '')}\n\`\`\`` }] }
+    },
+  }))
+}

+ 30 - 0
packages/bash/tool-pwsh/src/invariant.ts

@@ -0,0 +1,30 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-tool-pwsh`.
+ * @module @deepseek-ai/dsh-tool-pwsh/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-tool-pwsh'
+
+/** Cordis companion plugin name. */
+export const name = 'tool-pwsh-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: this package exposes no independent event sequence or mutable data relation
+ * beyond contracts enforced at its owning seam.
+ */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */

+ 119 - 0
packages/bash/tool-pwsh/tests/integration.spec.ts

@@ -0,0 +1,119 @@
+/**
+ * Integration tests: the REAL `@deepseek-ai/dsh-pwsh-local` executor plus the
+ * `pwsh` tool, exercised through `ctx.tools.execute()` with a real PowerShell
+ * process. These verify the world — actual commands run, stdout/stderr come
+ * back, exit codes render, timeouts abort, and per-session cwd resolution
+ * works. The suite self-skips when no `pwsh` is on PATH (a CI accommodation
+ * for hosts without PowerShell); the fake-executor suite (tools.spec.ts)
+ * carries the coverage gate.
+ */
+
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { spawnSync } from 'node:child_process'
+import { Context } from 'cordis'
+import { CallId } from '@deepseek-ai/dsh-llm'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry, { TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
+import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
+import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local'
+import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
+
+const testToolSignal = new AbortController().signal
+
+const hasPwsh = spawnSync('pwsh', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
+
+/** Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). */
+const lf = (text: string): string => text.replace(/\r\n/g, '\n')
+
+let dir: string
+let ctx: Context
+
+let callCounter = 0
+function call(name: string, args: unknown, agentObj?: object, signal?: AbortSignal) {
+  return ctx.tools.execute({
+    signal: signal ?? testToolSignal,
+    callId: CallId(`it-${++callCounter}`),
+    name,
+    arguments: args,
+    ...agentObj ? { agent: agentObj as never } : {},
+  })
+}
+
+function text(result: { content: { type: string; text?: string }[] }): string {
+  return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
+}
+
+describe.skipIf(!hasPwsh)('pwsh tool over the real pwsh executor', () => {
+  beforeEach(async () => {
+    dir = await mkdtemp(join(tmpdir(), 'dsh-tool-pwsh-'))
+    await writeFile(join(dir, 'greeting.txt'), 'hello pwsh\n')
+
+    ctx = new Context()
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRegistry)
+    await ctx.plugin(LocalSubprocessService)
+    await ctx.plugin(PwshLocalExecutor, { timeoutMs: 20_000, graceMs: 200 })
+    await ctx.plugin(ToolPwsh)
+  })
+
+  afterEach(async () => {
+    await rm(dir, { recursive: true, force: true })
+  })
+
+  const agent = () => ({ session: { header: { id: 'session-int', cwd: dir } } })
+
+  it('runs a command and returns stdout with the exit marker', async () => {
+    const result = await call('pwsh', { command: 'Write-Output hi', description: 'say hi' }, agent())
+    expect(result.isError).toBe(false)
+    if (result.isError) throw new Error('expected pwsh success')
+    expect(result.value).toMatchObject({ kind: 'foreground', exitCode: 0 })
+    expect(lf(text(result))).toBe('hi\n[exit code: 0]')
+  })
+
+  it('returns stderr in a marked section and a nonzero exit as a marker, not an error', async () => {
+    const result = await call('pwsh', {
+      command: '[Console]::Error.WriteLine("boom"); exit 3',
+      description: 'fail loudly',
+    }, agent())
+    expect(result.isError).toBe(false)
+    expect(lf(text(result))).toBe('[stderr]\nboom\n[exit code: 3]')
+  })
+
+  it('resolves relative paths in the session workspace', async () => {
+    const result = await call('pwsh', {
+      command: 'Get-Content greeting.txt',
+      description: 'read greeting',
+    }, agent())
+    expect(result.isError).toBe(false)
+    expect(lf(text(result))).toBe('hello pwsh\n[exit code: 0]')
+  })
+
+  it('a per-call timeout kills the run and reports the timed-out marker, not an error', async () => {
+    const result = await call('pwsh', {
+      command: 'Start-Sleep -Seconds 60',
+      description: 'sleep forever',
+      timeoutMs: 100,
+    }, agent())
+    expect(result.isError).toBe(false)
+    if (result.isError) throw new Error('expected a timed-out foreground result')
+    expect(result.value).toMatchObject({ kind: 'foreground', timedOut: true, aborted: false })
+    // Windows reports the forced termination as exit 1 without a signal;
+    // POSIX reports SIGTERM — the timeout marker is the stable fact.
+    expect(lf(text(result))).toContain('[timed out after 100ms]')
+  })
+
+  it('an upstream cancellation aborts the run', async () => {
+    const controller = new AbortController()
+    const pending = call('pwsh', {
+      command: 'Start-Sleep -Seconds 60',
+      description: 'sleep forever',
+    }, agent(), controller.signal)
+    setTimeout(() => { controller.abort() }, 50)
+    const result = await pending
+    expect(result.isError).toBe(true)
+    expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED } })
+  })
+})

+ 296 - 0
packages/bash/tool-pwsh/tests/tools.spec.ts

@@ -0,0 +1,296 @@
+/**
+ * Consumer-surface tests for the `pwsh` tool over a FAKE bash executor,
+ * exercised through `ctx.tools.execute()` so nothing bypasses the tool
+ * registry. The fake executor makes every seam outcome scriptable — output
+ * text, truncation, timeout, abort, nonzero exits — so these tests verify the
+ * schema, argument validation, workdir derivation, managed `DSH_*` collection,
+ * abort translation, canonical result projection, rendering, and the UI
+ * presenters. Real-pwsh behavior is pinned separately in integration.spec.ts.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import { mkdtempSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join, resolve as resolvePath } from 'node:path'
+import { CallId } from '@deepseek-ai/dsh-llm'
+import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry, { TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
+import { BashExecutor } from '@deepseek-ai/dsh-bash'
+import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
+import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
+
+const testToolSignal = new AbortController().signal
+
+/**
+ * A scriptable fake executor: `resolve()` mirrors the real defaulting, `run()`
+ * returns the armed script, `start()` throws — the pwsh tool must NEVER create
+ * a background task.
+ */
+class FakeBash extends BashExecutor {
+  requests: BashExecRequest[] = []
+  specs: BashExecSpec[] = []
+  startCalls = 0
+  handler: (spec: BashExecSpec) => BashRunResult = () => runResult('')
+
+  override resolve(request: BashExecRequest): BashExecSpec {
+    this.requests.push(request)
+    return {
+      command: request.command,
+      workdir: request.workdir ?? process.cwd(),
+      timeoutMs: request.timeoutMs ?? 60_000,
+      stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
+      ...request.signal ? { signal: request.signal } : {},
+      ...request.stdin !== undefined ? { stdin: request.stdin } : {},
+      ...request.env !== undefined ? { env: request.env } : {},
+      ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
+      sandboxPolicy: request.sandboxPolicy,
+    }
+  }
+
+  override async run(spec: BashExecSpec): Promise<BashRunResult> {
+    this.specs.push(spec)
+    return this.handler(spec)
+  }
+
+  override start(): BashProcess {
+    this.startCalls++
+    throw new Error('the pwsh tool must never start a background task')
+  }
+}
+
+/** A successful run result over the given stdout; overrides script the failure shapes. */
+function runResult(stdout: string, overrides?: Partial<BashRunResult>): BashRunResult {
+  return {
+    exitCode: 0,
+    signal: null,
+    timedOut: false,
+    aborted: false,
+    timeoutMs: 60_000,
+    stdout: { text: stdout, truncated: false },
+    stderr: { text: '', truncated: false },
+    ...overrides,
+  }
+}
+
+async function setup(config: Partial<ToolPwsh.Config> = {}) {
+  const ctx = new Context()
+  await ctx.plugin(SystemPrompt)
+  await ctx.plugin(ToolRegistry)
+  await ctx.plugin(FakeBash)
+  await ctx.plugin(ToolPwsh, config)
+  const bash = ctx.bash as FakeBash
+  return { ctx, bash }
+}
+
+/** A stand-in agent whose session header carries the given cwd and id. */
+const agent = (cwd?: string, id = 'session-1') => ({ session: { header: { id, ...cwd !== undefined ? { cwd } : {} } } })
+
+let callCounter = 0
+function call(
+  ctx: Context,
+  name: string,
+  args: unknown,
+  options: { agent?: object; signal?: AbortSignal } = {},
+) {
+  return ctx.tools.execute({
+    signal: testToolSignal,
+    callId: CallId(`call-${++callCounter}`),
+    name,
+    arguments: args,
+    ...options.agent ? { agent: options.agent as never } : {},
+    ...options.signal ? { signal: options.signal } : {},
+  })
+}
+
+function text(result: { content: { type: string; text?: string }[] }): string {
+  return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
+}
+
+describe('registration', () => {
+  it('registers the pwsh tool with its prompt section and schema', async () => {
+    const { ctx } = await setup()
+    const schema = ctx.tools.schemas().find(s => s.name === 'pwsh')
+    expect(schema).toBeDefined()
+    expect(schema?.description).toContain('PowerShell command')
+    expect(schema?.parameters.properties).toMatchObject({
+      command: { type: 'string' },
+      description: { type: 'string' },
+      timeoutMs: { type: 'number' },
+      workdir: { type: 'string' },
+    })
+    expect(schema?.parameters.required).toEqual(['command', 'description'])
+    const prompt = renderPrompt(await ctx.systemPrompt.assemble())
+    expect(prompt).toContain('Check the [exit code: N] marker on every pwsh result')
+  })
+
+  it('stays pending until ctx.bash exists (inject)', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRegistry)
+    await ctx.plugin(ToolPwsh)
+    expect(ctx.tools.schemas()).toHaveLength(0)
+  })
+
+  it('unregisters everything on fiber disposal (HMR safety)', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRegistry)
+    await ctx.plugin(FakeBash)
+    const fiber = await ctx.plugin(ToolPwsh)
+    expect(ctx.tools.schemas()).toHaveLength(1)
+    await fiber.dispose()
+    expect(ctx.tools.schemas()).toHaveLength(0)
+  })
+})
+
+describe('argument validation', () => {
+  it('rejects a blank command or description and a non-positive timeoutMs', async () => {
+    const { ctx } = await setup()
+    expect(text(await call(ctx, 'pwsh', { command: '  ', description: 'd' }))).toContain('expected a non-empty string')
+    expect(text(await call(ctx, 'pwsh', { command: 'Write-Output hi', description: ' ' }))).toContain('expected a non-empty string')
+    expect(text(await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'd', timeoutMs: -1 })))
+      .toContain('invalid timeoutMs: expected a positive number')
+  })
+})
+
+describe('execution through the bash seam', () => {
+  it('forwards command, session cwd, timeout, and managed DSH_* environment', async () => {
+    const dshHome = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-home-'))
+    const { ctx, bash } = await setup({ dshHome })
+    bash.handler = () => runResult('hi\n')
+    const result = await call(ctx, 'pwsh', {
+      command: 'Write-Output hi',
+      description: 'say hi',
+      timeoutMs: 1234,
+    }, { agent: agent('/sessions/s1') })
+    expect(result.isError).toBe(false)
+    const request = bash.requests[0]
+    expect(request?.command).toBe('Write-Output hi')
+    expect(request?.workdir).toBe('/sessions/s1')
+    expect(request?.timeoutMs).toBe(1234)
+    expect(request?.dshEnv).toEqual({
+      DSH_HOME: dshHome,
+      DSH_SHELL: '1',
+      DSH_SESSION_ID: 'session-1',
+    })
+    expect(bash.specs[0]?.workdir).toBe('/sessions/s1')
+  })
+
+  it('resolves a relative workdir against the session cwd, absolute ones verbatim', async () => {
+    const { ctx, bash } = await setup()
+    bash.handler = () => runResult('ok\n')
+    await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: 'sub/dir' }, { agent: agent('/sessions/s1') })
+    expect(bash.requests[0]?.workdir).toBe(resolvePath('/sessions/s1', 'sub/dir'))
+    await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: resolvePath('/abs/path') }, { agent: agent('/sessions/s1') })
+    expect(bash.requests[1]?.workdir).toBe(resolvePath('/abs/path'))
+  })
+
+  it('omits workdir and the session id without an agent, so executor defaulting applies', async () => {
+    const { ctx, bash } = await setup()
+    bash.handler = () => runResult('ok\n')
+    await call(ctx, 'pwsh', { command: 'Write-Output ok', description: 'ok' })
+    expect(bash.requests[0]).not.toHaveProperty('workdir')
+    const dshEnv = bash.requests[0]?.dshEnv
+    expect(dshEnv).toBeDefined()
+    expect(dshEnv?.['DSH_SHELL']).toBe('1')
+    expect(dshEnv?.['DSH_HOME']).toEqual(expect.any(String))
+    expect(dshEnv).not.toHaveProperty('DSH_SESSION_ID')
+  })
+
+  it('forwards exec.signal into the resolved request', async () => {
+    const { ctx, bash } = await setup()
+    const controller = new AbortController()
+    bash.handler = () => runResult('ok\n')
+    await call(ctx, 'pwsh', { command: 'Write-Output ok', description: 'ok' }, { signal: controller.signal })
+    expect(bash.requests[0]?.signal).toBe(controller.signal)
+  })
+
+  it('projects the canonical foreground result with stdout, stderr, and exit facts', async () => {
+    const { ctx, bash } = await setup()
+    bash.handler = () => runResult('out\n', {
+      exitCode: 2,
+      stderr: { text: 'err\n', truncated: false },
+      timeoutMs: 5000,
+    })
+    const result = await call(ctx, 'pwsh', { command: 'failing', description: 'fail' })
+    expect(result.isError).toBe(false)
+    if (result.isError) throw new Error('expected pwsh success')
+    expect(result.value).toEqual({
+      kind: 'foreground',
+      exitCode: 2,
+      signal: null,
+      timedOut: false,
+      aborted: false,
+      timeoutMs: 5000,
+      stdout: { text: 'out\n', truncated: false },
+      stderr: { text: 'err\n', truncated: false },
+    })
+    expect(text(result)).toBe('out\n[stderr]\nerr\n[exit code: 2]')
+  })
+
+  it('renders the truncation tail, the exit marker, and a timeout marker from the executor streams', async () => {
+    const { ctx, bash } = await setup()
+    bash.handler = () => runResult('tail', {
+      stdout: { text: 'tail', truncated: true, spillPath: '/spill/out.log' },
+      stderr: { text: '', truncated: false },
+    })
+    const result = await call(ctx, 'pwsh', { command: 'noisy', description: 'noise' })
+    expect(text(result)).toBe('tail\n[exit code: 0]')
+
+    bash.handler = () => runResult('', { timedOut: true, exitCode: null, signal: 'SIGTERM', timeoutMs: 500 })
+    const timedOut = await call(ctx, 'pwsh', { command: 'slow', description: 'slow' })
+    // A timeout kill carries both facts, mirroring the bash tool's markers.
+    expect(text(timedOut)).toBe('[timed out after 500ms]\n[killed by signal: SIGTERM]')
+  })
+
+  it('translates an aborted run into the TOOL_ABORTED HarnessError', async () => {
+    const { ctx, bash } = await setup()
+    bash.handler = () => runResult('', { aborted: true, exitCode: null, signal: 'SIGTERM' })
+    const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'sleep' })
+    expect(result.isError).toBe(true)
+    expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED } })
+  })
+
+  it('never starts a background task', async () => {
+    const { ctx, bash } = await setup()
+    bash.handler = () => runResult('ok\n')
+    await call(ctx, 'pwsh', { command: 'Write-Output ok', description: 'ok' })
+    bash.handler = () => runResult('', { exitCode: 1 })
+    await call(ctx, 'pwsh', { command: 'missing', description: 'missing' })
+    expect(bash.startCalls).toBe(0)
+  })
+})
+
+describe('UI presentation', () => {
+  it('a real execute renders the console view through the tool definition presenter', async () => {
+    const { ctx, bash } = await setup()
+    bash.handler = () => runResult('hi\n')
+    const args = { command: 'Write-Output hi', description: 'say hi' }
+    const result = await call(ctx, 'pwsh', args, { agent: agent('/w') })
+    const view = ctx.tools.get('pwsh')?.presentResult?.(args, result)
+    expect(view).toEqual({
+      card: 'generic',
+      content: [{ type: 'text', text: '```console\nhi\n[exit code: 0]\n```' }],
+    })
+  })
+
+  it('the pending call view is a terminal card carrying command, description, and optional cwd', async () => {
+    const { ctx } = await setup()
+    const definition = ctx.tools.get('pwsh')
+    expect(definition?.presentCall?.({ command: 'Get-Process', description: 'List processes' }))
+      .toEqual({ card: 'terminal', title: 'Get-Process', description: 'List processes' })
+    expect(definition?.presentCall?.({ command: 'Get-Process', description: 'List processes', workdir: 'C:\\work' }))
+      .toMatchObject({ cwd: 'C:\\work' })
+  })
+
+  it('presentResult falls back to undefined for multi-block or non-text content', async () => {
+    const { ctx } = await setup()
+    const definition = ctx.tools.get('pwsh')
+    const args = { command: 'Write-Output hi', description: 'say hi' }
+    const multi = { content: [{ type: 'text' as const, text: 'a' }, { type: 'text' as const, text: 'b' }], isError: false }
+    expect(definition?.presentResult?.(args, multi as never)).toBeUndefined()
+    const image = { content: [{ type: 'image' as const, text: 'a' }], isError: false }
+    expect(definition?.presentResult?.(args, image as never)).toBeUndefined()
+  })
+})

+ 45 - 0
packages/bash/tool-pwsh/tsconfig.json

@@ -0,0 +1,45 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cosmokit"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../../llm/llm"
+    },
+    {
+      "path": "../../core/tools"
+    },
+    {
+      "path": "../../core/agent"
+    },
+    {
+      "path": "../../session-persistence/session-persistence"
+    },
+    {
+      "path": "../../bash/bash"
+    },
+    {
+      "path": "../../util/paths"
+    },
+    {
+      "path": "../../core/system-prompt"
+    },
+    {
+      "path": "../../support/invariants"
+    }
+  ]
+}

+ 71 - 0
pnpm-lock.yaml

@@ -309,6 +309,9 @@ importers:
       '@deepseek-ai/dsh-pty-local':
       '@deepseek-ai/dsh-pty-local':
         specifier: workspace:^
         specifier: workspace:^
         version: link:../../packages/pty/pty-local
         version: link:../../packages/pty/pty-local
+      '@deepseek-ai/dsh-pwsh-local':
+        specifier: workspace:^
+        version: link:../../packages/bash/pwsh-local
       '@deepseek-ai/dsh-repeat-tool-guard':
       '@deepseek-ai/dsh-repeat-tool-guard':
         specifier: workspace:^
         specifier: workspace:^
         version: link:../../packages/guard/repeat-tool-guard
         version: link:../../packages/guard/repeat-tool-guard
@@ -426,6 +429,9 @@ importers:
       '@deepseek-ai/dsh-tool-goal':
       '@deepseek-ai/dsh-tool-goal':
         specifier: workspace:^
         specifier: workspace:^
         version: link:../../packages/goal/tool-goal
         version: link:../../packages/goal/tool-goal
+      '@deepseek-ai/dsh-tool-pwsh':
+        specifier: workspace:^
+        version: link:../../packages/bash/tool-pwsh
       '@deepseek-ai/dsh-tool-ralph':
       '@deepseek-ai/dsh-tool-ralph':
         specifier: workspace:^
         specifier: workspace:^
         version: link:../../packages/workflow/tool-ralph
         version: link:../../packages/workflow/tool-ralph
@@ -941,6 +947,31 @@ importers:
         specifier: 0.0.0-test.0
         specifier: 0.0.0-test.0
         version: 0.0.0-test.0
         version: 0.0.0-test.0
 
 
+  packages/bash/pwsh-local:
+    dependencies:
+      schemastery:
+        specifier: ^3.18.0
+        version: 3.18.0
+    devDependencies:
+      '@deepseek-ai/dsh-bash':
+        specifier: workspace:^
+        version: link:../bash
+      '@deepseek-ai/dsh-invariants':
+        specifier: workspace:^
+        version: link:../../support/invariants
+      '@deepseek-ai/dsh-subprocess':
+        specifier: workspace:^
+        version: link:../../subprocess/subprocess
+      '@deepseek-ai/dsh-subprocess-local':
+        specifier: workspace:^
+        version: link:../../subprocess/subprocess-local
+      '@deepseek-ai/dsh-timeout':
+        specifier: workspace:^
+        version: link:../../util/timeout
+      cordis:
+        specifier: ^4.0.0-rc.7
+        version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+
   packages/bash/tool-bash:
   packages/bash/tool-bash:
     dependencies:
     dependencies:
       schemastery:
       schemastery:
@@ -1011,6 +1042,46 @@ importers:
         specifier: ^4.0.0-rc.7
         specifier: ^4.0.0-rc.7
         version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
         version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
 
 
+  packages/bash/tool-pwsh:
+    dependencies:
+      schemastery:
+        specifier: ^3.18.0
+        version: 3.18.0
+    devDependencies:
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../core/agent
+      '@deepseek-ai/dsh-bash':
+        specifier: workspace:^
+        version: link:../bash
+      '@deepseek-ai/dsh-invariants':
+        specifier: workspace:^
+        version: link:../../support/invariants
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-paths':
+        specifier: workspace:^
+        version: link:../../util/paths
+      '@deepseek-ai/dsh-pwsh-local':
+        specifier: workspace:^
+        version: link:../pwsh-local
+      '@deepseek-ai/dsh-session-persistence':
+        specifier: workspace:^
+        version: link:../../session-persistence/session-persistence
+      '@deepseek-ai/dsh-subprocess-local':
+        specifier: workspace:^
+        version: link:../../subprocess/subprocess-local
+      '@deepseek-ai/dsh-system-prompt':
+        specifier: workspace:^
+        version: link:../../core/system-prompt
+      '@deepseek-ai/dsh-tools':
+        specifier: workspace:^
+        version: link:../../core/tools
+      cordis:
+        specifier: ^4.0.0-rc.7
+        version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+
   packages/client/connection:
   packages/client/connection:
     dependencies:
     dependencies:
       '@deepseek-ai/dsh-commands':
       '@deepseek-ai/dsh-commands':

+ 19 - 0
scripts/gen-tool-catalog.ts

@@ -17,6 +17,7 @@ import GoalService from '@deepseek-ai/dsh-goal'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
 import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
 import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
 import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
+import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local'
 import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
 import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
 import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
 import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
 import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
 import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
@@ -31,6 +32,7 @@ import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
 import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
 import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
 import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
 import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
 import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
 import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
+import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
 import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent'
 import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent'
 import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
 import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
 import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
 import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
@@ -167,6 +169,23 @@ const TOOL_PACKAGES: ToolPackage[] = [
     note:
     note:
       'The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled.',
       'The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled.',
   },
   },
+  {
+    pkg: '@deepseek-ai/dsh-tool-pwsh',
+    dir: 'tool-pwsh',
+    source: 'packages/bash/tool-pwsh/src/index.ts',
+    requires: ['ctx.tools', 'ctx.bash', 'ctx.systemPrompt'],
+    writes: ['tool/call', 'tool/result'],
+    async mount(ctx) {
+      // The pwsh tool consumes the bash executor seam; the schema harvest
+      // mounts the pwsh-local implementation so the inject resolves without
+      // executing anything (registration never spawns a process).
+      await ctx.plugin(LocalSubprocessService)
+      await ctx.plugin(PwshLocalExecutor)
+      await ctx.plugin(ToolPwsh)
+    },
+    note:
+      'The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); minimal by design — foreground only, no sandbox escalation, native `C:\\...` paths and `$env:NAME` variables.',
+  },
   {
   {
     pkg: '@deepseek-ai/dsh-tool-cordis',
     pkg: '@deepseek-ai/dsh-tool-cordis',
     dir: 'tool-cordis',
     dir: 'tool-cordis',

+ 1 - 0
scripts/verify-package-readme-model-experience.ts

@@ -43,6 +43,7 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly<Record<string, string>> = {
 const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
 const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/bash/bash': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' },
   'packages/bash/bash': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' },
   'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' },
   'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' },
+  'packages/bash/pwsh-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-pwsh.' },
   'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' },
   'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' },
   'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' },
   'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' },
   'packages/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' },
   'packages/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' },

+ 2 - 0
tsconfig.base.json

@@ -52,6 +52,8 @@
       "@deepseek-ai/dsh-session-title/client": ["./packages/session-title/session-title/src/client.ts"],
       "@deepseek-ai/dsh-session-title/client": ["./packages/session-title/session-title/src/client.ts"],
       "@deepseek-ai/dsh-plan-mode/types": ["./packages/plan/plan-mode/src/types.ts"],
       "@deepseek-ai/dsh-plan-mode/types": ["./packages/plan/plan-mode/src/types.ts"],
       "@deepseek-ai/dsh-plan-mode/client": ["./packages/plan/plan-mode/src/client.ts"],
       "@deepseek-ai/dsh-plan-mode/client": ["./packages/plan/plan-mode/src/client.ts"],
+      "@deepseek-ai/dsh-pwsh-local": ["./packages/bash/pwsh-local/src/index.ts"],
+      "@deepseek-ai/dsh-tool-pwsh": ["./packages/bash/tool-pwsh/src/index.ts"],
       "@deepseek-ai/dsh-goal/types": ["./packages/goal/goal/src/types.ts"],
       "@deepseek-ai/dsh-goal/types": ["./packages/goal/goal/src/types.ts"],
       "@deepseek-ai/dsh-goal/client": ["./packages/goal/goal/src/client.ts"],
       "@deepseek-ai/dsh-goal/client": ["./packages/goal/goal/src/client.ts"],
       "@deepseek-ai/dsh-llm/types": ["./packages/llm/llm/src/types.ts"],
       "@deepseek-ai/dsh-llm/types": ["./packages/llm/llm/src/types.ts"],

+ 2 - 0
tsconfig.host.json

@@ -136,6 +136,8 @@
     { "path": "./packages/llm/llm-deepseek" },
     { "path": "./packages/llm/llm-deepseek" },
     { "path": "./packages/llm/llm-pi-ai" },
     { "path": "./packages/llm/llm-pi-ai" },
     { "path": "./packages/bash/bash-local" },
     { "path": "./packages/bash/bash-local" },
+    { "path": "./packages/bash/pwsh-local" },
+    { "path": "./packages/bash/tool-pwsh" },
     { "path": "./packages/sandbox/sandbox" },
     { "path": "./packages/sandbox/sandbox" },
     { "path": "./packages/sandbox/sandbox-local" },
     { "path": "./packages/sandbox/sandbox-local" },
     { "path": "./packages/sandbox/sandbox-policy" },
     { "path": "./packages/sandbox/sandbox-policy" },

+ 7 - 1
vitest.config.ts

@@ -11,7 +11,13 @@ const pathsPlugin = (): ReturnType<typeof tsconfigPaths> => tsconfigPaths({ proj
 
 
 const windowsUnsupportedPackages = process.platform === 'win32'
 const windowsUnsupportedPackages = process.platform === 'win32'
   ? [
   ? [
-      'packages/bash/*',
+      // Bash-requiring suites (a real POSIX shell is unavailable on Windows).
+      // The pwsh-requiring suites (pwsh-local, tool-pwsh) deliberately stay
+      // INCLUDED: PowerShell ships with Windows, so they run natively here.
+      'packages/bash/bash-local',
+      'packages/bash/bash-sandbox',
+      'packages/bash/tool-bash',
+      'packages/bash/tool-bash-persistent',
       'packages/hooks/*',
       'packages/hooks/*',
       'packages/subprocess/*',
       'packages/subprocess/*',
       'packages/pty/pty-local',
       'packages/pty/pty-local',