瀏覽代碼

Merge branch 'master' into worktree/web-session-model-selector

imccyu 2 月之前
父節點
當前提交
6d8e5fb3a2
共有 64 個文件被更改,包括 972 次插入 和 1814 次删除
  1. 2 2
      .agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml
  2. 1 1
      .agents/notes/implemented/feature/2026-07-06-sandbox.md
  3. 1 1
      .agents/notes/implemented/feature/2026-07-06-sandbox.zh.md
  4. 6 0
      .agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml
  5. 34 0
      .agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md
  6. 34 0
      .agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md
  7. 2 2
      .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml
  8. 3 1
      .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md
  9. 3 1
      .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md
  10. 6 0
      .agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml
  11. 37 0
      .agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md
  12. 37 0
      .agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md
  13. 0 6
      .agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml
  14. 0 31
      .agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md
  15. 0 31
      .agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md
  16. 0 6
      .agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml
  17. 0 41
      .agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md
  18. 0 41
      .agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md
  19. 2 2
      .agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml
  20. 1 1
      .agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md
  21. 1 1
      .agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md
  22. 2 2
      .agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml
  23. 2 2
      .agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md
  24. 2 2
      .agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md
  25. 5 15
      .github/workflows/build-exe-for-python-sdk.yml
  26. 120 235
      .github/workflows/ci.yml
  27. 3 14
      .github/workflows/docs-pages.yml
  28. 3 14
      .github/workflows/e2e.yml
  29. 3 14
      .github/workflows/pi-ai-provider-e2e.yml
  30. 2 3
      .github/workflows/sandbox.yml
  31. 1 0
      .gitignore
  32. 1 0
      AGENTS.md
  33. 12 19
      apps/cli/tests/built-bin.e2e.ts
  34. 3 12
      apps/web/tests/scaffold.ts
  35. 3 14
      apps/web/tests/smoke-real.e2e.ts
  36. 21 35
      examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts
  37. 26 29
      examples/tui-agent/tests/pty-harness.ts
  38. 1 0
      knip.json
  39. 3 0
      package.json
  40. 0 590
      packages/client/runtime/src/client/sessions/service.ts.orig
  41. 0 98
      packages/client/ui-workspace/src/client/index.ts.orig
  42. 0 321
      packages/client/ui-workspace/src/client/tree.ts.orig
  43. 8 7
      packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts
  44. 15 20
      packages/examples/acp-demo/tests/built-bin.e2e.ts
  45. 25 27
      packages/examples/cli-demo/tests/built-bin.e2e.ts
  46. 8 7
      packages/lsp/lsp-local/tests/built-lib.e2e.ts
  47. 2 0
      packages/sdk/create-sdk/tests/link-workspace.e2e.ts
  48. 26 29
      packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts
  49. 2 2
      packages/support/acp-snapshot/README.i18n.yaml
  50. 1 1
      packages/support/acp-snapshot/README.md
  51. 1 1
      packages/support/acp-snapshot/README.zh.md
  52. 22 19
      packages/support/acp-snapshot/src/harness.ts
  53. 51 60
      packages/support/llm-mock-server/src/cli.ts
  54. 7 3
      packages/support/llm-mock-server/tests/cli.spec.ts
  55. 2 2
      packages/support/loader-smoke/README.i18n.yaml
  56. 1 1
      packages/support/loader-smoke/README.md
  57. 1 1
      packages/support/loader-smoke/README.zh.md
  58. 1 0
      packages/support/loader-smoke/package.json
  59. 20 46
      packages/support/loader-smoke/src/index.ts
  60. 136 0
      pnpm-lock.yaml
  61. 33 0
      scripts/client-tsconfig.spec.ts
  62. 1 1
      scripts/doc-budgets.manifest.json
  63. 223 0
      scripts/wine-windows-gates.sh
  64. 3 0
      tsconfig.client.json

+ 2 - 2
.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml

@@ -2,5 +2,5 @@
 # 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
-2026-07-06-sandbox.md: 723ef170188dc11da24e049a1e2838fb240d0a17
-2026-07-06-sandbox.zh.md: a8c7743bb3d499fb58f507ea2c202b44efe2311d
+2026-07-06-sandbox.md: c6883873192f15ba2982436e156d8795396c0148
+2026-07-06-sandbox.zh.md: d84df9b06b15dd296801073d381603f34cfd2878

+ 1 - 1
.agents/notes/implemented/feature/2026-07-06-sandbox.md

@@ -128,7 +128,7 @@ Each phase gets its full design when picked up, validated against the code at th
 
 - **Second consumer** — `subagent-acp` optionally confines child agents (per-call policy; unconfined default — a child agent must write its own persistence).
 - **More environments** — an environment-coherent capability group example (e.g. bash+fs against one container).
-- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect and denial/runner-failure signatures.
+- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect and denial/runner-failure signatures. Wrapping the third-party landstrip runner instead was [considered and rejected](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — not battle-tested enough for a security invariant.
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md

@@ -128,7 +128,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层
 
 - **第二个消费方**——`subagent-acp` 可选地约束子 agent(按调用策略;默认无约束——子 agent 必须写入自己的持久化)。
 - **更多环境**——环境一致的能力组示例(如 bash+fs 对一个容器)。
-- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言和拒绝/runner 失败签名。
+- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言和拒绝/runner 失败签名。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——对安全不变式而言,它还远未经过实战检验。
 
 ## 曾考虑的替代方案
 

+ 6 - 0
.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.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/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md
+2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 006360cedfd4d1e2c2b67ede98062a375e316f47
+2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: d511e7a2b89788fe8addd2cc47634742c7a87fe4

+ 34 - 0
.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md

@@ -0,0 +1,34 @@
+# Agent Note: Provision CI pnpm via pnpm/action-setup
+
+Status: implemented
+
+English | [中文](2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md)
+
+## Problem
+
+Outside `landlock-run.yml`, each workflow that installed pnpm hand-provisioned it with `corepack enable`, and five of them further repeated a hand-rolled cache setup — `pnpm store path --silent >> $GITHUB_OUTPUT`, then `actions/cache@v4` keyed on `pnpm-lock.yaml`: `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat, serial-linux, and benchmark jobs of `ci.yml`. The maintained equivalent — `pnpm/action-setup@v4` (reads `packageManager` from package.json) plus `actions/setup-node` with `cache: pnpm` — was already proven in-repo in `landlock-run.yml`, and corepack's removal from newer Node distributions made every `corepack enable` a known future break.
+
+## Decision
+
+`pnpm/action-setup@v4` is the only pnpm provisioning mechanism in CI: no workflow runs `corepack enable`. The root dev dependency on `@yarnpkg/cli-dist` separately supplies the modern Yarn CLI exercised by the generated-project e2e; package-manager coverage therefore does not inherit the runner image's Yarn Classic. Caching remains per-job policy on top of pnpm provisioning, in three deliberate shapes:
+
+- **Symmetric cache** (restore and save): `actions/setup-node` with `cache: pnpm` — `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat and two benchmark jobs of `ci.yml`. The larger-runner benchmark keeps its store cache Linux-only through a conditional `cache:` input; the consolidated benchmark caches on both platforms.
+- **Restore-only / producer pairing** (hand-rolled `actions/cache` steps): the three enterprise-runner PR jobs and the Wine-based pull-request Windows job restore without saving, keeping cache compression/upload off their latency-sensitive paths — an asymmetry `setup-node`'s cache cannot express. Each configures a store outside the action's replaceable install directory and resolves that path, matching the master-push serial-linux producer's path and exact key; the enterprise jobs skip restore during self-hosted failover because that VM's persistent store is already warm.
+- **Cache-less or persistent** (no store-cache action): native serial-windows and serial-macos plus `sandbox.yml` install from a cold or runner-local store. The self-hosted standby and failover jobs reuse their VM's persistent pnpm store without transferring a hosted cache archive.
+
+## Alternatives considered
+
+- **Keep the hand-rolled steps.** They worked, but they were drifting copies of setup boilerplate, and the corepack dependency was a known future break.
+- **Convert the enterprise jobs' caching to `cache: pnpm`.** Rejected: the restore-only asymmetry is a documented latency decision in `ci.yml`'s comments; erasing it to unify tooling inverts the priority.
+- **Convert serial-linux's store cache.** Rejected during implementation: the original proposal counted serial-linux among the symmetric setups, but its cache step is the producer half of the enterprise jobs' restore-only pairing — moving it to `setup-node`'s key format is the enterprise conversion by another route.
+- **Stop at the cache-bearing workflows and leave the other `corepack enable` sites.** Rejected on review follow-up: provisioning and caching are separable concerns, and leaving corepack in the cache-less jobs kept the future break and two provisioning idioms for no benefit.
+- **Rely on the runner image's Yarn.** Rejected: the hosted image exposes Yarn 1.22 after Corepack is removed, while the generated-project e2e requires Yarn 2 or newer. A locked root dev dependency makes that coverage independent of runner image contents.
+- **A composite action wrapping action-setup + setup-node.** Rejected for now: the remaining per-job variation (node-version matrices, per-platform conditional caching, the restore-only pairing) is deliberate policy, not boilerplate — a wrapper would grow mirroring inputs or flatten a real asymmetry, and the two-line pair is already near the floor.
+
+## Consequences
+
+- The corepack dependency is gone from CI entirely; pnpm arrives via the pnpm team's official action everywhere, and the version pin stays single-sourced in `package.json`'s `packageManager` field.
+- The generated-project e2e runs the root-pinned Yarn 4 CLI instead of inheriting or silently skipping the runner image's Yarn version.
+- The cache-key format changed once for converted lanes; one cold run repopulated it, after which hit rates match the old steps. The built-in key spans platform, arch, and the lockfile hash but not the Node version, so the node-compat matrix legs share one store entry — safe, because the pnpm store is Node-version-independent.
+- `setup-node`'s built-in pnpm cache restores by exact key only, with no `restore-keys` prefix fallback: a `pnpm-lock.yaml` change starts a converted lane from a cold store instead of seeding from the previous entry.
+- `pnpm/action-setup` deletes its install directory on every run and places the default store beneath the resulting `PNPM_HOME`. Linux jobs that need cache pairing or self-hosted persistence therefore set `PNPM_CONFIG_STORE_DIR` to `$HOME/.local/share/pnpm/store`, outside the action directory; the restore-only jobs and serial-linux resolve and share that stable path and exact key.

+ 34 - 0
.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md

@@ -0,0 +1,34 @@
+# Agent Note: 经由 pnpm/action-setup 提供 CI 的 pnpm
+
+Status: implemented
+
+[English](2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md) | 中文
+
+## 问题
+
+除 `landlock-run.yml` 外,每个安装 pnpm 的工作流都曾用 `corepack enable` 手工提供 pnpm,其中五个还各自重复着一套手写(hand-rolled)的缓存设置——`pnpm store path --silent >> $GITHUB_OUTPUT`、再加以 `pnpm-lock.yaml` 为缓存键的 `actions/cache@v4`:`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat、serial-linux 与 benchmark 作业。与之等价、由官方维护的做法——`pnpm/action-setup@v4`(从 package.json 读取 `packageManager`)加带 `cache: pnpm` 的 `actions/setup-node`——当时已在仓库内的 `landlock-run.yml` 中得到验证,而 corepack 被从较新 Node 发行版中移除,使每一处 `corepack enable` 都成了已知的未来失效点。
+
+## 决策
+
+`pnpm/action-setup@v4` 是 CI 中提供 pnpm 的唯一机制:没有任何工作流运行 `corepack enable`。根目录的 `@yarnpkg/cli-dist` 开发依赖另行提供 generated-project e2e 所运行的现代 Yarn CLI(命令行界面);因此,用于包管理器覆盖率的 Yarn 不会沿用 runner 镜像里的 Yarn Classic。缓存仍是叠加在 pnpm 提供机制上的按作业政策,保持三种刻意的形态:
+
+- **对称缓存**(既恢复也保存):带 `cache: pnpm` 的 `actions/setup-node`——`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat 与两个 benchmark 作业。larger-runner benchmark 通过条件化的 `cache:` 输入让 store 缓存仅限 Linux;consolidated benchmark 在两个平台上都启用缓存。
+- **只恢复不上传/生产者配对**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PR(Pull Request)作业与基于 Wine 的拉取请求 Windows 作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store,并解析该路径,从而与 master 推送触发的 serial-linux 生产者所用的路径和精确键匹配;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已能直接提供热安装。
+- **无缓存或持久化**(不使用 store 缓存 action):原生 serial-windows 和 serial-macos 加上 `sandbox.yml` 从冷 store 或 runner 本地 store 安装。自托管热备与故障切换作业复用其 VM 的持久 pnpm store,不传输托管缓存归档。
+
+## 曾考虑的替代方案
+
+- **保留手写步骤。** 它们能用,但那是会各自漂移的设置样板副本,而且对 corepack 的依赖是已知的未来失效点。
+- **把企业作业的缓存也转换成 `cache: pnpm`。** 否决:只恢复不上传的不对称是 `ci.yml` 注释中有记录的延迟决策;为统一工具而抹掉它,属于颠倒优先级。
+- **转换 serial-linux 的 store 缓存。** 实现期间否决:原提案曾把 serial-linux 计入对称设置,但其缓存步骤是企业作业只恢复不上传配对中的生产者一半——把它改成 `setup-node` 的键格式,等于换条路径做了企业作业的转换。
+- **只转换带缓存的工作流,留下其余 `corepack enable` 站点。** 评审跟进时否决:提供 pnpm 与缓存是可分离的关注点,在无缓存作业里留下 corepack 只会保留未来失效点和两套并存的提供方式,毫无收益。
+- **依赖 runner 镜像自带的 Yarn。** 否决:Corepack 移除后,托管镜像提供的是 Yarn 1.22,而 generated-project e2e 要求 Yarn 2 或更高版本。锁定版本的根开发依赖让该项覆盖率不再受 runner 镜像内容影响。
+- **用一个组合 action 包装 action-setup + setup-node。** 暂不采纳:剩余的按作业差异(node 版本矩阵、按平台的条件缓存、只恢复不上传配对)是刻意的政策而非样板——包装层要么长出镜像这些差异的输入,要么抹平一处真实的不对称,而两行的组合已接近下限。
+
+## 后果
+
+- corepack 依赖已从 CI 中彻底消失;pnpm 在所有工作流中都经由 pnpm 团队的官方 action 提供,版本锁定继续单一来源于 `package.json` 的 `packageManager` 字段。
+- generated-project e2e 运行根目录锁定的 Yarn 4 CLI,既不再沿用 runner 镜像中的 Yarn 版本,也不会因此悄然跳过。
+- 已转换泳道的缓存键格式变更了一次;各跑一次冷运行重建缓存后,命中率与旧步骤持平。内建缓存键涵盖平台、架构与锁文件哈希,但不含 Node 版本,因此 node-compat 矩阵的各条腿共享同一条 store 缓存记录——这是安全的,因为 pnpm store 与 Node 版本无关。
+- `setup-node` 内建的 pnpm 缓存只按精确键恢复,没有 `restore-keys` 前缀回退:`pnpm-lock.yaml` 一旦变更,已转换泳道会从冷 store 起步,而不是从上一条缓存记录播种。
+- `pnpm/action-setup` 每次运行都会删除其安装目录,并把默认 store 放在由此产生的 `PNPM_HOME` 下。因此,需要缓存配对或自托管持久化的 Linux 作业会把 `PNPM_CONFIG_STORE_DIR` 设为 `$HOME/.local/share/pnpm/store`,置于 action 目录之外;只恢复不上传的作业与 serial-linux 会解析并共享这一稳定路径及精确键。

+ 2 - 2
.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml

@@ -2,5 +2,5 @@
 # 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/process/2026-07-27-wine-windows-gates-experiment.md
-2026-07-27-wine-windows-gates-experiment.md: aab8aecdfca06c1f15641044a071015f543a84b6
-2026-07-27-wine-windows-gates-experiment.zh.md: 5239b185e1e0c63aa626ee3f20f3f298c0c8579d
+2026-07-27-wine-windows-gates-experiment.md: 640c8e455b1a35ea4ac83454227147b9979316dc
+2026-07-27-wine-windows-gates-experiment.zh.md: f30e09ca7411ef83d02faf012ce54d6c6c65dff1

+ 3 - 1
.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md

@@ -18,7 +18,9 @@ Dependencies install natively on Linux with `supportedArchitectures` extended to
 
 The lane holds the wall clock of the Linux CI jobs through four levers: the master-refreshed pnpm store cache (restore-only, same key as the Linux jobs), Wine provisioning (apt install, Windows Node download, `wineboot`) running concurrently with `pnpm install`, the two blocking surfaces running concurrently — the same shape `run-gates` gives them on native Windows — and an apt-archive cache keyed on the runner image, seeded from master by the `wine apt cache` job so every pull request restores from the default-branch scope.
 
-Four environment constraints shape the job, each found as a red run: Ubuntu's `wine64` package alone puts nothing on PATH (install `wine`, the dispatcher); Node under Wine cannot attach stdio to the Actions runner's pipes (`Socket open EBADF` at bootstrap — every invocation routes stdio through a file); Wine does not realpath pnpm's isolated-layout Unix symlinks (the hoisted layout above); and Wine cannot create Windows symlinks (`ENOTSUP` from VitePress's `linkVue` — the `vue` link is laid down host-side before the gate).
+The gate logic lives in one script, [scripts/wine-windows-gates.sh](../../../../scripts/wine-windows-gates.sh): the ci.yml job provisions runner state (caches, apt Wine) and calls it, and the optional local gate `pnpm run check:windows-wine` runs the identical script on a developer machine that has Wine installed — one implementation, so local reproduction of a red CI lane needs no translation between environments. The local gate is a diagnosis tool, not a routine check: run it only when investigating a known Windows-related failure; CI owns the everyday win32 signal, and [dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) never selects it. The script never mutates the working tree: it snapshots tracked plus untracked-unignored files into a scratch directory, applies the Wine-specific pnpm overrides to the snapshot only, and installs there against the shared store; the Wine prefix and the checksum-verified Windows Node zip persist under `.cache/wine-windows/` so local reruns skip provisioning, with an offline fallback to the newest cached zip when nodejs.org is unreachable.
+
+Five environment constraints shape CI and local execution, each found as a red run: Ubuntu's `wine64` package alone puts nothing on PATH (install `wine`, the dispatcher); Node under Wine cannot attach stdio to the caller's pipes (`Socket open EBADF` at bootstrap — every invocation routes stdio through a file); Wine does not realpath pnpm's isolated-layout Unix symlinks (the hoisted layout above); macOS Wine also exposes hoisted workspace links as ordinary directories, so the client test aggregate includes every package-local CSS module declaration instead of relying on project-reference realpaths; and Wine cannot create Windows symlinks (`ENOTSUP` from VitePress's `linkVue` — the `vue` link is laid down host-side before the gate).
 
 ## Measured results
 

+ 3 - 1
.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md

@@ -18,7 +18,9 @@ Pull request 的 Windows 通道存在的意义是证明两个阻断性 win32 表
 
 该通道靠四个杠杆保持 Linux CI 作业的墙钟:master 刷新的 pnpm store 缓存(只恢复,与 Linux 作业同键)、Wine 供给(apt 安装、Windows Node 下载、`wineboot`)与 `pnpm install` 并发运行、两个阻断表面并发运行——与 `run-gates` 在原生 Windows 上给它们的形状相同——以及按 runner 镜像为键的 apt 归档缓存,由 master 的 `wine apt cache` 作业播种,使每个 pull request 都能从默认分支作用域恢复。
 
-四条环境约束塑造了该作业,每条都以一次红色运行被发现:Ubuntu 的 `wine64` 包本身不往 PATH 放任何东西(要装 `wine` 调度器);Wine 下的 Node 无法把 stdio 接到 Actions runner 的管道上(引导期 `Socket open EBADF`——所有调用都经文件中转 stdio);Wine 不对 pnpm isolated 布局的 Unix 符号链接做 realpath(即上文的 hoisted 布局);Wine 无法创建 Windows 符号链接(VitePress 的 `linkVue` 报 `ENOTSUP`——`vue` 链接在门禁前由宿主侧铺好)。
+门禁逻辑集中在一个脚本里,[scripts/wine-windows-gates.sh](../../../../scripts/wine-windows-gates.sh):ci.yml 作业只供给 runner 状态(缓存、apt Wine)然后调用它,可选的本地门禁 `pnpm run check:windows-wine` 在装有 Wine 的开发机上运行同一个脚本——单一实现,因此本地复现红色 CI 通道不需要在环境之间做任何转译。该本地门禁是诊断工具而非例行检查:仅在排查已知的 Windows 相关失败时运行;日常 win32 信号归 CI 所有,[dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) 也从不选择它。脚本从不改动工作树:把被跟踪加未跟踪未忽略的文件快照进一个临时目录,只对快照施加 Wine 特有的 pnpm 覆盖,并在那里对着共享 store 安装;Wine prefix 与校验和验证过的 Windows Node zip 持久存放在 `.cache/wine-windows/` 下,本地重跑跳过供给,nodejs.org 不可达时回退到最新的已缓存 zip。
+
+五条环境约束塑造了 CI 与本地执行,每条都以一次红色运行被发现:Ubuntu 的 `wine64` 包本身不往 PATH 放任何东西(要装 `wine` 调度器);Wine 下的 Node 无法把 stdio 接到调用方的管道上(引导期 `Socket open EBADF`——所有调用都经文件中转 stdio);Wine 不对 pnpm isolated 布局的 Unix 符号链接做 realpath(即上文的 hoisted 布局);macOS Wine 也会把 hoisted workspace 链接暴露为普通目录,因此 client 测试聚合会纳入每个包自己的 CSS 模块声明,而不依赖 project-reference realpath;Wine 无法创建 Windows 符号链接(VitePress 的 `linkVue` 报 `ENOTSUP`——`vue` 链接在门禁前由宿主侧铺好)。
 
 ## 实测结果
 

+ 6 - 0
.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.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/testing/2026-07-26-execa-for-test-subprocess-plumbing.md
+2026-07-26-execa-for-test-subprocess-plumbing.md: 958abc4aee94adb3e6206cc299595ad92bde4044
+2026-07-26-execa-for-test-subprocess-plumbing.zh.md: 7027a8bde51f81bfa7774743f84639cbd4b667d8

+ 37 - 0
.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md

@@ -0,0 +1,37 @@
+# Agent Note: Adopt execa for hand-rolled test subprocess plumbing
+
+Status: implemented
+
+English | [中文](2026-07-26-execa-for-test-subprocess-plumbing.zh.md)
+
+## Problem
+
+Roughly ten e2e/smoke files re-derived the same spawn-collect-timeout choreography by hand: `let stdout = ''` accumulation with `setEncoding` and `data` handlers, a `setTimeout` → `kill('SIGKILL')` deadline, and `once('exit')`/`once('error')` settlement, each with small variations. The sites: the inner spawn block of `runLoaderSmoke` (`packages/support/loader-smoke/src/index.ts`), `runBuiltBin` in `apps/cli/tests/built-bin.e2e.ts` and `packages/examples/cli-demo/tests/built-bin.e2e.ts`, `runBinExpectingExit` in `packages/examples/acp-demo/tests/built-bin.e2e.ts`, the built-lib e2e helpers in `lsp-local` and `code-runtime-worker`, the outer collector of `examples/tui-agent/tests/pty-harness.ts`, `examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts`, and partially `apps/web/tests/smoke-real.e2e.ts` and `session-checkpoint-policy/tests/crash-recovery.e2e.ts`.
+
+Two related test-infra hand-rolls compounded the case:
+
+- `packages/support/llm-mock-server/src/cli.ts` hand-tokenized 17 value-taking `--flag value` options plus boolean flags (~45–60 lines of loop and value-extraction helpers) where the `node:util` `parseArgs` builtin is already the repo idiom (`cli-demo`, `acp-demo`, `verify-runtime-closure.ts`, `packages/sdk/scripts`).
+- `apps/web/tests/smoke-real.e2e.ts` and `apps/web/tests/scaffold.ts` carried two verbatim copies of a regex `.env` parser (~20 lines) where the `process.loadEnvFile` builtin has exactly the required no-override semantics — and the vitest e2e/snapshot/web configs already load root `.env` with it before these files run, making the copies dead.
+- The snapshot harness hand-rolled three poll-until-deadline loops (`waitForPersistedTurnStart`/`waitForPersistedTurnEnd`/`waitForWorkspaceFile` in `packages/support/acp-snapshot/src/harness.ts`, ~55 lines) plus `waitForFile` in `crash-recovery.e2e.ts`, where `vi.waitFor`/`expect.poll` cover the shape — vitest is already a runtime dependency of `dsh-acp-snapshot`, so this adds nothing.
+
+## Decision
+
+- `execa` is a root devDependency and a runtime dependency of `@deepseek-ai/dsh-loader-smoke` (the one `src/` consumer). The listed spawn-collect-timeout sites run through `await execa(cmd, args, { cwd, env, timeout, killSignal: 'SIGKILL', reject: false })`, whose result reports `{ stdout, stderr, exitCode, signal, timedOut, failed }` as independent fields — matching the repo's own defensive-patterns rule to report orthogonal subprocess outcomes independently. `runLoaderSmoke` passes `input: ''` for its stdin-close contract, and sites whose assertions pin exact stream bytes pass `stripFinalNewline: false`.
+- The genuinely custom parts stay custom on top of an execa-owned subprocess: cli-demo's interrupt-on-marker mid-stream logic, jsonrpc's line-predicate protocol driving, and crash-recovery's SIGKILL-at-failpoint choreography. `smoke-real.e2e.ts` keeps raw `spawn` for its three long-lived interactive servers — ready-line watching across both streams plus a staged SIGTERM→await→SIGKILL teardown are the whole site, so execa would delete nothing there; its share of this note is the dead `.env` parser.
+- `llm-mock-server`'s CLI tokenizes via `parseArgs` (strict, no positionals); numeric coercion, bounds, and cross-option constraints stay manual, and the pinned error-message tests carry `parseArgs`'s own tokenizer texts.
+- Both `loadRootEnv` copies are deleted outright: the owning vitest configs (`vitest.web.config.ts` unconditionally, `vitest.snapshot.config.ts` in record mode) load the repo-root `.env` before those files run.
+- The four poll loops ride `vi.waitFor` with explicit `{ interval, timeout }` and descriptive errors thrown from the callback; `waitForPersistedTurnStart` captures its malformed-record validation error out of the retry loop so it fails the run immediately instead of being retried until the deadline.
+
+## Alternatives considered
+
+- **`tinyexec` instead of execa.** Already in `node_modules` transitively via vitest, smaller API — but no kill-escalation, no rich error output embedding, and being transitive is not a contract; if the lighter package is preferred the swap shape is identical.
+- **A repo-local shared spawn helper (no new dep).** Viable and cheaper on supply chain, but it keeps the maintenance of deadline/kill/settlement logic in-repo when a battle-tested package owns exactly this; contrary to the [dependency policy](../process/2026-07-26-dependencies-over-hand-rolling.md), it also has to re-earn cross-platform timeout, termination, and result-normalization behavior that execa already carries.
+- **`get-port`, `wait-on`, `tempy`, `tree-kill`.** Rejected individually: the repo's single port probe is break-even, the file waits are dominated by `vi.waitFor`, temp-dir handling already uses `mkdtemp` + `rm {recursive}` builtins everywhere, and acp-snapshot's `close()` is drain-ordering logic, not tree traversal.
+
+## Consequences
+
+- The hand-rolled collect/timeout blocks are gone, including the two `/* v8 ignore */` un-inducible OS-error branches in `loader-smoke`: spawn and stream failures settle through execa's result fields, so the `src/` file carries no coverage exemptions and the per-file gate covers every remaining branch.
+- Captured output is bounded by execa's default 100 MB `maxBuffer` (overflow terminates the subprocess) where it was previously unbounded; the `loader-smoke` README's limitation entry reflects this.
+- Direct-child timeout termination and exit/signal result normalization are owned by execa across platforms instead of per-site hand-rolls; process-tree termination remains outside these helpers, as the `loader-smoke` README states. Each rewritten suite was re-run on POSIX in this change, and the Windows CI lanes own the other platform.
+- execa is a new root devDependency (previously absent from the lockfile); it is one of the most-depended-on packages on npm and actively maintained, and the exe/runtime closure is unaffected (tests only).
+- The mock-server CLI's tokenizer-level error texts are no longer this repo's to choose: unknown options, missing values, and stray positionals report `parseArgs`'s wording, pinned as such in `tests/cli.spec.ts`.

+ 37 - 0
.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md

@@ -0,0 +1,37 @@
+# Agent Note: 采用 execa 替换手写的测试子进程管道代码
+
+Status: implemented
+
+[English](2026-07-26-execa-for-test-subprocess-plumbing.md) | 中文
+
+## 问题
+
+大约十个 e2e/冒烟测试文件各自手工重写过同一套「spawn、收集输出、超时终止」编排:用 `setEncoding` 加 `data` 处理器做 `let stdout = ''` 式累积,用 `setTimeout` → `kill('SIGKILL')` 设定超时截止,再以 `once('exit')`/`once('error')` 结算结果,各处只有细微差别。这些位置是:`runLoaderSmoke` 的内层 spawn 代码块(`packages/support/loader-smoke/src/index.ts`)、`apps/cli/tests/built-bin.e2e.ts` 与 `packages/examples/cli-demo/tests/built-bin.e2e.ts` 中的 `runBuiltBin`、`packages/examples/acp-demo/tests/built-bin.e2e.ts` 中的 `runBinExpectingExit`、`lsp-local` 与 `code-runtime-worker` 中基于构建产物的 e2e 辅助函数、`examples/tui-agent/tests/pty-harness.ts` 的外层收集器、`examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts`,以及部分涉及的 `apps/web/tests/smoke-real.e2e.ts` 和 `session-checkpoint-policy/tests/crash-recovery.e2e.ts`。
+
+另有两处相关的测试基础设施手写代码进一步强化了替换的理由:
+
+- `packages/support/llm-mock-server/src/cli.ts` 曾手工逐个切分 17 个带值的 `--flag value` 选项外加若干布尔标志(约 45–60 行的循环与取值辅助函数),而 `node:util` 内置的 `parseArgs` 早已是本仓库的惯用写法(`cli-demo`、`acp-demo`、`verify-runtime-closure.ts`、`packages/sdk/scripts`)。
+- `apps/web/tests/smoke-real.e2e.ts` 与 `apps/web/tests/scaffold.ts` 曾携带两份逐字相同的正则 `.env` 解析器拷贝(约 20 行),而内置的 `process.loadEnvFile` 恰好具备所需的「不覆盖已有值」语义;并且 vitest 的 e2e/snapshot/web 配置在这些文件运行之前就已用它加载了根 `.env`,这两份拷贝实为死代码。
+- 快照 harness 曾手写三个「轮询直到截止时间」的循环(`packages/support/acp-snapshot/src/harness.ts` 中的 `waitForPersistedTurnStart`/`waitForPersistedTurnEnd`/`waitForWorkspaceFile`,约 55 行),外加 `crash-recovery.e2e.ts` 中的 `waitForFile`,而 `vi.waitFor`/`expect.poll` 正好覆盖这种形态;vitest 本来就是 `dsh-acp-snapshot` 的运行时依赖,因此这不新增任何东西。
+
+## 决定
+
+- `execa` 是根 devDependency,同时是 `@deepseek-ai/dsh-loader-smoke`(唯一的 `src/` 消费者)的运行时依赖。上述 spawn、收集、超时的代码位置统一经由 `await execa(cmd, args, { cwd, env, timeout, killSignal: 'SIGKILL', reject: false })` 运行:其结果以相互独立的字段报告 `{ stdout, stderr, exitCode, signal, timedOut, failed }`,与本仓库防御模式中「正交的子进程结果各自独立上报」的规则一致。`runLoaderSmoke` 传 `input: ''` 以兑现其 stdin 关闭契约;断言固定精确流字节的位置传 `stripFinalNewline: false`。
+- 真正定制的部分继续保持定制,只是架在 execa 拥有的子进程之上:cli-demo 在流中遇到标记即中断的逻辑、jsonrpc 基于行谓词的协议驱动,以及 crash-recovery 在故障点发送 SIGKILL 的编排。`smoke-real.e2e.ts` 的三个长驻交互式服务器保留原生 `spawn`——跨双流监听就绪行加上分级的 SIGTERM→等待→SIGKILL 拆除就是该处的全部内容,execa 在那里删不掉任何东西;它在本 note 中的份额是那份死的 `.env` 解析器。
+- `llm-mock-server` 的 CLI 经由 `parseArgs` 切分(strict、不允许位置参数);数值转换、边界检查与跨选项约束仍手工实现,被固定的错误消息测试改为携带 `parseArgs` 自己的切分器文本。
+- 两份 `loadRootEnv` 拷贝被整体删除:拥有它们的 vitest 配置(`vitest.web.config.ts` 无条件、`vitest.snapshot.config.ts` 在 record 模式下)在这些文件运行之前就加载了仓库根部的 `.env`。
+- 那四个轮询循环改乘 `vi.waitFor`,显式传入 `{ interval, timeout }`,并在回调中抛出带描述信息的错误;`waitForPersistedTurnStart` 把「持久化记录格式非法」的校验错误捕获到重试循环之外,使其立即让运行失败,而不是被重试到截止时间。
+
+## 曾考虑的替代方案
+
+- **用 `tinyexec` 代替 execa。**它已经作为 vitest 的传递依赖存在于 `node_modules` 中,API 也更小;但它没有终止信号逐级升级,不会把丰富的输出嵌入错误对象,而且传递依赖并不构成契约。如果最终更倾向这个更轻的包,替换的形态完全相同。
+- **仓库内共享的 spawn 辅助函数(不引入新依赖)。**可行,供应链成本也更低,但当一个久经实战的包恰好负责这件事时,它把截止时限、终止与结算逻辑的维护留在了仓库内;这与[依赖策略](../process/2026-07-26-dependencies-over-hand-rolling.md)背道而驰,它还得重新踩坑换来 execa 已经自带的跨平台超时、终止与结果规范化行为。
+- **`get-port`、`wait-on`、`tempy`、`tree-kill`。**逐一不予采纳:仓库仅有的一处端口探测替换后收支相抵;文件等待场景已由 `vi.waitFor` 更优地覆盖;临时目录处理在各处已经使用内置的 `mkdtemp` + `rm {recursive}`;acp-snapshot 的 `close()` 是排空顺序逻辑,不是进程树遍历。
+
+## 后果
+
+- 手写的收集/超时代码块全部移除,包括 `loader-smoke` 中两个标注 `/* v8 ignore */`、无法人为诱发的 OS 错误分支:spawn 与流故障如今经由 execa 的结果字段结算,这个 `src/` 文件不再携带任何覆盖率豁免,逐文件门禁覆盖其余全部分支。
+- 捕获的输出如今受 execa 默认 100 MB `maxBuffer` 约束(溢出即终止子进程),此前是无界的;`loader-smoke` README 的局限条目反映了这一点。
+- 直接子进程的超时终止以及退出/信号结果规范化均由 execa 跨平台负责,不再逐处手写;如 `loader-smoke` README 所述,这些辅助函数依然不负责终止进程树。每个改写后的套件在本次变更中已在 POSIX 上重新运行,另一平台由 Windows CI 车道负责。
+- execa 是新增的根 devDependency(此前完全不存在于 lockfile 中);它是 npm 上被依赖最多的包之一且维护活跃,exe/运行时闭包不受影响(仅测试使用)。
+- mock-server CLI 切分器层面的错误文本不再由本仓库决定:未知选项、缺失取值与多余位置参数报告 `parseArgs` 的措辞,并在 `tests/cli.spec.ts` 中如此固定。

+ 0 - 6
.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml

@@ -1,6 +0,0 @@
-# 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
-2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 63e3f45ab2340ee2b732da286117e25be45bed08
-2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 2348e07d58f7f0ed39a1759cc30133c8e15dbc4a

+ 0 - 31
.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md

@@ -1,31 +0,0 @@
-# Agent Note: Use pnpm/action-setup for symmetric CI pnpm caching
-
-Status: proposed
-
-English | [中文](2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md)
-
-## Problem
-
-Five workflows repeat a hand-rolled three-step pnpm setup — `corepack enable`, `pnpm store path --silent >> $GITHUB_OUTPUT`, then `actions/cache@v4` keyed on `pnpm-lock.yaml`: `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat, serial-linux, and benchmark jobs of `ci.yml` (~40–60 YAML lines total). The maintained equivalent — `pnpm/action-setup@v4` (reads `packageManager` from package.json) plus `actions/setup-node` with `cache: pnpm` — is already proven in-repo in `landlock-run.yml`, and also insulates against corepack's removal from newer Node distributions.
-
-## Proposal
-
-Convert the symmetric-cache workflows to `pnpm/action-setup@v4` + `setup-node` `cache: pnpm`. Explicitly do NOT convert:
-
-- the three enterprise-runner PR jobs in `ci.yml` — they deliberately use `actions/cache/restore` only, keeping cache compression/upload off the paid latency-critical path, an asymmetry `setup-node`'s cache cannot express;
-- the Windows job, which deliberately skips the store cache.
-
-## Alternatives considered
-
-- **Keep the hand-rolled steps.** They work, but they are five drifting copies of setup boilerplate, and the corepack dependency is a known future break.
-- **Convert everything including the enterprise jobs.** Rejected: the restore-only asymmetry is a documented latency decision in `ci.yml`'s comments; erasing it to unify tooling inverts the priority.
-
-## Acceptance criteria
-
-- The five symmetric workflows set up pnpm via the actions; one cold run per lane repopulates the new cache-key format, after which cache hit rates match the old steps.
-- The enterprise-runner PR jobs and the Windows job are untouched.
-
-## Risks
-
-- Cache-key format changes once (one cold run per lane).
-- A third-party action in more workflows; it is already trusted in-repo (`landlock-run.yml`) and is the pnpm team's official action.

+ 0 - 31
.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md

@@ -1,31 +0,0 @@
-# Agent Note: 用 pnpm/action-setup 实现对称的 CI pnpm 缓存
-
-Status: proposed
-
-[English](2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md) | 中文
-
-## 问题
-
-五个工作流重复着同一套手写(hand-rolled)的三步 pnpm 设置——`corepack enable`、`pnpm store path --silent >> $GITHUB_OUTPUT`、再加以 `pnpm-lock.yaml` 为缓存键的 `actions/cache@v4`:`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat、serial-linux 与 benchmark 作业(合计约 40–60 行 YAML)。与之等价、由官方维护的做法——`pnpm/action-setup@v4`(从 package.json 读取 `packageManager`)加带 `cache: pnpm` 的 `actions/setup-node`——已在仓库内的 `landlock-run.yml` 中得到验证,同时还能隔绝 corepack 被从较新 Node 发行版中移除的影响。
-
-## 提案
-
-将各对称缓存工作流改为 `pnpm/action-setup@v4` + `setup-node` `cache: pnpm`。以下明确不做转换:
-
-- `ci.yml` 中运行在企业 runner 上的三个 PR(Pull Request)作业——它们刻意只用 `actions/cache/restore`,把缓存压缩/上传挡在付费且延迟敏感的关键路径之外,这种不对称是 `setup-node` 的缓存无法表达的;
-- Windows 作业,它刻意跳过 store 缓存。
-
-## 曾考虑的替代方案
-
-- **保留手写步骤。** 它们能用,但那是五份会各自漂移的设置样板副本,而且对 corepack 的依赖是已知的未来失效点。
-- **连企业作业在内全部转换。** 否决:只恢复不上传(restore-only)的不对称是 `ci.yml` 注释中有记录的延迟决策;为统一工具而抹掉它,属于颠倒优先级。
-
-## 验收标准
-
-- 五个对称工作流经由上述 action 完成 pnpm 设置;每条泳道各跑一次冷运行以重建新的缓存键格式,此后缓存命中率与旧步骤持平。
-- 企业 runner 上的 PR 作业与 Windows 作业保持原样不动。
-
-## 风险
-
-- 缓存键格式变更一次(每条泳道各一次冷运行)。
-- 更多工作流引入一个第三方 action;它已在仓库内获得信任(`landlock-run.yml`),且是 pnpm 团队的官方 action。

+ 0 - 6
.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml

@@ -1,6 +0,0 @@
-# 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
-2026-07-26-execa-for-test-subprocess-plumbing.md: 99a86258fe4d59db6a0e144dbcee94c095f70f8f
-2026-07-26-execa-for-test-subprocess-plumbing.zh.md: 525e09f07ce3e5dc61f1cadab5c11ea0790cccee

+ 0 - 41
.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md

@@ -1,41 +0,0 @@
-# Agent Note: Adopt execa for hand-rolled test subprocess plumbing
-
-Status: proposed
-
-English | [中文](2026-07-26-execa-for-test-subprocess-plumbing.zh.md)
-
-## Problem
-
-Roughly ten e2e/smoke files re-derive the same spawn-collect-timeout choreography by hand: `let stdout = ''` accumulation with `setEncoding` and `data` handlers, a `setTimeout` → `kill('SIGKILL')` deadline, and `once('exit')`/`once('error')` settlement, each with small variations. The sites: the inner spawn block of `runLoaderSmoke` (`packages/support/loader-smoke/src/index.ts`), `runBuiltBin` in `apps/cli/tests/built-bin.e2e.ts` and `packages/examples/cli-demo/tests/built-bin.e2e.ts`, `runBinExpectingExit` in `packages/examples/acp-demo/tests/built-bin.e2e.ts`, the built-lib e2e helpers in `lsp-local` and `code-runtime-worker`, the outer collector of `examples/tui-agent/tests/pty-harness.ts`, `examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts`, and partially `apps/web/tests/smoke-real.e2e.ts` and `session-checkpoint-policy/tests/crash-recovery.e2e.ts`. Net deletable: ~100–150 lines of test infrastructure.
-
-Two related test-infra hand-rolls compound the case:
-
-- `packages/support/llm-mock-server/src/cli.ts` hand-tokenizes 17 value-taking `--flag value` options plus boolean flags (~45–60 lines of loop and value-extraction helpers) where the `node:util` `parseArgs` builtin is already the repo idiom (`cli-demo`, `acp-demo`, `verify-runtime-closure.ts`, `packages/sdk/scripts`).
-- `apps/web/tests/smoke-real.e2e.ts` and `apps/web/tests/scaffold.ts` carry two verbatim copies of a regex `.env` parser (~20 lines) where the `process.loadEnvFile` builtin has exactly the required no-override semantics — and the vitest e2e/snapshot/web configs already load root `.env` with it before these files run, making the copies arguably dead.
-- The snapshot harness hand-rolls three poll-until-deadline loops (`waitForPersistedTurnStart`/`waitForPersistedTurnEnd`/`waitForWorkspaceFile` in `packages/support/acp-snapshot/src/harness.ts`, ~55 lines) plus `waitForFile` in `crash-recovery.e2e.ts`, where `vi.waitFor`/`expect.poll` cover the shape — vitest is already a runtime dependency of `dsh-acp-snapshot`, so this adds nothing.
-
-## Proposal
-
-- Add `execa` as a root devDependency and rewrite the spawn-collect-timeout sites onto `await execa(cmd, args, { cwd, env, timeout, killSignal: 'SIGKILL', reject: false })`, whose result reports `{ stdout, stderr, exitCode, signal, timedOut }` as independent fields — matching the repo's own defensive-patterns rule to report orthogonal subprocess outcomes independently. Keep the genuinely custom parts custom: cli-demo's interrupt-on-marker mid-stream logic, jsonrpc's line-predicate protocol driving, and crash-recovery's SIGKILL-at-failpoint choreography.
-- Swap `llm-mock-server`'s CLI tokenizer for `parseArgs` (numeric coercion, bounds, and cross-option constraints stay manual; pinned error-message texts update with the tests).
-- Delete both `loadRootEnv` copies in favor of `process.loadEnvFile` in a try/catch, or remove them outright if the vitest-config loading already covers them.
-- Replace the four poll loops with `vi.waitFor`/`expect.poll`, passing explicit `{ interval, timeout }` and throwing descriptive errors from the callback.
-
-## Alternatives considered
-
-- **`tinyexec` instead of execa.** Already in `node_modules` transitively via vitest, smaller API — but no kill-escalation, no rich error output embedding, and being transitive is not a contract; if the lighter package is preferred the swap shape is identical.
-- **A repo-local shared spawn helper (no new dep).** Viable and cheaper on supply chain, but it keeps the maintenance of deadline/kill/settlement logic in-repo when a battle-tested package owns exactly this; contrary to the [dependency policy](../../implemented/process/2026-07-26-dependencies-over-hand-rolling.md), it also has to re-earn Windows behavior (taskkill, exit codes) that execa already carries.
-- **`get-port`, `wait-on`, `tempy`, `tree-kill`.** Rejected individually: the repo's single port probe is break-even, the file waits are dominated by `vi.waitFor`, temp-dir handling already uses `mkdtemp` + `rm {recursive}` builtins everywhere, and acp-snapshot's `close()` is drain-ordering logic, not tree traversal.
-
-## Acceptance criteria
-
-- The listed sites spawn through execa (or the chosen equivalent); the hand-rolled collect/timeout blocks and the two `/* v8 ignore */` un-inducible OS-error branches in `loader-smoke` are gone.
-- `llm-mock-server` CLI parses via `parseArgs`; its cli spec passes with updated message expectations.
-- No hand-rolled `.env` parser remains under `apps/web/tests`.
-- The affected e2e and snapshot suites pass on both POSIX and Windows CI lanes.
-
-## Risks
-
-- `loader-smoke` is a `src/` file under the per-file-100% coverage gate; the swap actually simplifies its coverage story (removes un-inducible branches) but the new call shape needs coverage.
-- Each rewritten e2e must be re-run on both platforms; subtle differences in kill escalation or stdin-close semantics (`input: ''` for loader-smoke's stdin-close contract) are the risk to verify per site.
-- execa is a new root devDependency (currently absent from the lockfile entirely); it is one of the most-depended-on packages on npm and actively maintained, so health is not a concern, but the exe/runtime closure is unaffected either way (tests only).

+ 0 - 41
.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md

@@ -1,41 +0,0 @@
-# Agent Note: 采用 execa 替换手写的测试子进程管道代码
-
-Status: proposed
-
-[English](2026-07-26-execa-for-test-subprocess-plumbing.md) | 中文
-
-## 问题
-
-大约十个 e2e/冒烟测试文件各自手工重写同一套「spawn、收集输出、超时终止」编排:用 `setEncoding` 加 `data` 处理器做 `let stdout = ''` 式累积,用 `setTimeout` → `kill('SIGKILL')` 设定超时截止,再以 `once('exit')`/`once('error')` 结算结果,各处只有细微差别。这些位置是:`runLoaderSmoke` 的内层 spawn 代码块(`packages/support/loader-smoke/src/index.ts`)、`apps/cli/tests/built-bin.e2e.ts` 与 `packages/examples/cli-demo/tests/built-bin.e2e.ts` 中的 `runBuiltBin`、`packages/examples/acp-demo/tests/built-bin.e2e.ts` 中的 `runBinExpectingExit`、`lsp-local` 与 `code-runtime-worker` 中基于构建产物的 e2e 辅助函数、`examples/tui-agent/tests/pty-harness.ts` 的外层收集器、`examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts`,以及部分涉及的 `apps/web/tests/smoke-real.e2e.ts` 和 `session-checkpoint-policy/tests/crash-recovery.e2e.ts`。净可删除量:约 100–150 行测试基础设施代码。
-
-另有两处相关的测试基础设施手写代码进一步强化了替换的理由:
-
-- `packages/support/llm-mock-server/src/cli.ts` 手工逐个切分 17 个带值的 `--flag value` 选项外加若干布尔标志(约 45–60 行的循环与取值辅助函数),而 `node:util` 内置的 `parseArgs` 早已是本仓库的惯用写法(`cli-demo`、`acp-demo`、`verify-runtime-closure.ts`、`packages/sdk/scripts`)。
-- `apps/web/tests/smoke-real.e2e.ts` 与 `apps/web/tests/scaffold.ts` 携带两份逐字相同的正则 `.env` 解析器拷贝(约 20 行),而内置的 `process.loadEnvFile` 恰好具备所需的「不覆盖已有值」语义;并且 vitest 的 e2e/snapshot/web 配置在这些文件运行之前就已用它加载了根 `.env`,这两份拷贝几乎可以视为死代码。
-- 快照 harness 手写了三个「轮询直到截止时间」的循环(`packages/support/acp-snapshot/src/harness.ts` 中的 `waitForPersistedTurnStart`/`waitForPersistedTurnEnd`/`waitForWorkspaceFile`,约 55 行),外加 `crash-recovery.e2e.ts` 中的 `waitForFile`,而 `vi.waitFor`/`expect.poll` 正好覆盖这种形态;vitest 本来就是 `dsh-acp-snapshot` 的运行时依赖,因此这不新增任何东西。
-
-## 提案
-
-- 将 `execa` 添加为根 devDependency,把上述 spawn、收集、超时的代码位置改写到 `await execa(cmd, args, { cwd, env, timeout, killSignal: 'SIGKILL', reject: false })` 上:其结果以相互独立的字段报告 `{ stdout, stderr, exitCode, signal, timedOut }`,与本仓库防御模式中「正交的子进程结果各自独立上报」的规则一致。真正定制的部分继续保持定制:cli-demo 在流中遇到标记即中断的逻辑、jsonrpc 基于行谓词的协议驱动,以及 crash-recovery 在故障点发送 SIGKILL 的编排。
-- 把 `llm-mock-server` 的 CLI 切分器换成 `parseArgs`(数值转换、边界检查与跨选项约束仍手工实现;被固定的错误消息文本随测试一并更新)。
-- 删除两份 `loadRootEnv` 拷贝,改用包在 try/catch 中的 `process.loadEnvFile`;如果 vitest 配置的加载已经覆盖了它们,则直接整体移除。
-- 用 `vi.waitFor`/`expect.poll` 替换那四个轮询循环,显式传入 `{ interval, timeout }`,并在回调中抛出带描述信息的错误。
-
-## 曾考虑的替代方案
-
-- **用 `tinyexec` 代替 execa。**它已经作为 vitest 的传递依赖存在于 `node_modules` 中,API 也更小;但它没有终止信号逐级升级,不会把丰富的输出嵌入错误对象,而且传递依赖并不构成契约。如果最终更倾向这个更轻的包,替换的形态完全相同。
-- **仓库内共享的 spawn 辅助函数(不引入新依赖)。**可行,供应链成本也更低,但当一个久经实战的包恰好负责这件事时,它把截止时限、终止与结算逻辑的维护留在了仓库内;这与[依赖策略](../../implemented/process/2026-07-26-dependencies-over-hand-rolling.md)背道而驰,它还得重新踩坑换来 execa 已经自带的 Windows 行为(taskkill、退出码)。
-- **`get-port`、`wait-on`、`tempy`、`tree-kill`。**逐一不予采纳:仓库仅有的一处端口探测替换后收支相抵;文件等待场景已由 `vi.waitFor` 更优地覆盖;临时目录处理在各处已经使用内置的 `mkdtemp` + `rm {recursive}`;acp-snapshot 的 `close()` 是排空顺序逻辑,不是进程树遍历。
-
-## 验收标准
-
-- 所列位置全部通过 execa(或最终选定的等价包)spawn 子进程;手写的收集/超时代码块,连同 `loader-smoke` 中两个标注 `/* v8 ignore */`、无法人为诱发的 OS 错误分支,全部移除。
-- `llm-mock-server` 的 CLI 经由 `parseArgs` 解析;其 cli 测试文件在更新消息期望后通过。
-- `apps/web/tests` 下不再存在手写的 `.env` 解析器。
-- 受影响的 e2e 与快照测试套件在 POSIX 与 Windows 两条 CI 车道上均通过。
-
-## 风险
-
-- `loader-smoke` 是逐文件 100% 覆盖率门禁下的 `src/` 文件;这次替换实际上简化了它的覆盖率问题(移除了无法人为诱发的分支),但新的调用形态需要补齐覆盖。
-- 每个改写后的 e2e 都必须在两个平台上重新运行;终止信号升级或 stdin 关闭语义上的细微差异(loader-smoke 的 stdin 关闭契约对应 `input: ''`)是需要逐处核验的风险。
-- execa 是新增的根 devDependency(当前完全不存在于 lockfile 中);它是 npm 上被依赖最多的包之一且维护活跃,健康度不是顾虑;至于 exe/运行时闭包,无论选哪个包都不受影响(仅测试使用)。

+ 2 - 2
.agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml → .agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml

@@ -2,5 +2,5 @@
 # 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
-2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md: 047449f4915c973e86cdb9f05f6dc51535133534
-2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md: 379d57e1e0006bf8f567d0b750ca0bb641ca6b49
+2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md: 236139f9198f178d44cdf0867cbad2377a127359
+2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md: 3932f73a2bf147ce5088b5c42e85982c70cdb945

+ 1 - 1
.agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md → .agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md

@@ -1,6 +1,6 @@
 # Agent Note: Evaluate landstrip before building a Windows sandbox launcher
 
-Status: proposed
+Status: rejected — landstrip is not battle-tested (a days-old single-maintainer project, ~48 GitHub stars at rejection); a security-invariant dependency must have proven adoption, so the win32 rung keeps the in-house-launcher plan
 
 English | [中文](2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md)
 

+ 1 - 1
.agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md → .agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md

@@ -1,6 +1,6 @@
 # Agent Note: 在构建 Windows 沙箱启动器之前先评估 landstrip
 
-Status: proposed
+Status: rejected — landstrip 未经实战检验(问世仅数天、单一维护者、驳回时 GitHub 星标约 48 个);安全不变式级的依赖必须有成熟的采用度,因此 win32 梯级维持自研启动器的原计划
 
 [English](2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) | 中文
 

+ 2 - 2
.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml

@@ -2,5 +2,5 @@
 # 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
-2026-07-26-dependency-swaps-rejected-by-nih-audit.md: f55bc2a9b7fb2a9599760734fca2a295665b95a2
-2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: f740e0717cffd1b2ff4f3f5db8c9775afdfe79f0
+2026-07-26-dependency-swaps-rejected-by-nih-audit.md: 9cdb061bf21a7b9ce4747b9022c65d60e9644e0d
+2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: 9648af149c0c517eba372baa12877240f9289aac

+ 2 - 2
.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md

@@ -46,7 +46,7 @@ Adopt the following dependency swaps. Rejected — per-item evidence below; a fu
 - **`shell-quote` for POSIX single-quoting**: two 1-line quoting helpers with exhaustive tests versus a maintenance-mode package with a CVE history and different escaping output — a safety boundary is the wrong place to save one line.
 - **`strip-ansi` for pty sanitization**: the pty sanitizer is a streaming state machine with split-sequence carry across chunks and OSC `133;D` prompt-marker extraction (the shell-readiness signal); stateless strippers replace ~20 inner lines while all state machinery stays. `stripVTControlCharacters` also demonstrably leaks unterminated-OSC payloads the session-title normalizer must strip (anti-spoofing).
 - **`pidtree`/`ps-tree` for the pty process inspector**: bare PID trees; the code needs start-time identity against PID reuse plus `/proc` stdin-wait detection no package does.
-- **`execa` for the subagent-subprocess dispose ladder**: `forceKillAfterDelay` covers SIGTERM→SIGKILL but not the stdin-EOF-first cooperative tier or the reject-if-no-exit-edge contract; adopting it here rewrites spawn sites while keeping the ladder. (Test-infrastructure spawn plumbing is different — see the [execa proposal](../../proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md).)
+- **`execa` for the subagent-subprocess dispose ladder**: `forceKillAfterDelay` covers SIGTERM→SIGKILL but not the stdin-EOF-first cooperative tier or the reject-if-no-exit-edge contract; adopting it here rewrites spawn sites while keeping the ladder. (Test-infrastructure spawn plumbing is different — see the [execa Agent Note](../../implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md).)
 - **`tree-kill` for acp-snapshot teardown and lsp process kill**: the lines are drain-ordering/error-propagation, not tree traversal; lsp/bash already use detached process groups + taskkill.
 - **node-pty everywhere for the TUI test driver**: [Windows-TUI note](../../implemented/feature/2026-07-20-windows-tui-support.md) explicitly rejected node-pty-on-every-host; it is already the Windows leg.
 
@@ -67,7 +67,7 @@ Adopt the following dependency swaps. Rejected — per-item evidence below; a fu
 - **`syncpack`/`manypkg` for `check-workspace-constraints.ts`**: they cover ~20 lines of range alignment; the load-bearing 200+ lines (computed `files` lists, cordis peer=dev pairing, hierarchy shape) are repo policy no generic engine expresses.
 - **`remark-validate-links` for `verify-md-links.ts`**: the gate rides the repo's shared mdast toolchain; adopting remark-cli adds a second markdown stack to delete one small file.
 - **`prebuildify`/`node-gyp-build` for the landlock launcher packaging**: inapplicable — those load `.node` addons via dlopen; the launcher ships a standalone exec'd static binary, and per-platform `optionalDependencies` *is* the ecosystem convention for binaries.
-- **Replacing the Landlock launcher itself with `@landstrip/landstrip`**: fails the security-invariant test — the launcher is a ~300-line reviewable C file with byte-pinned provenance that already migrated away from a Rust dependency; a single-maintainer LGPL Rust binary set is a larger audit surface with weaker provenance. (The unbuilt Windows rung is a different question — see the [landstrip evaluation proposal](../../proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md).)
+- **Replacing the Landlock launcher itself with `@landstrip/landstrip`**: fails the security-invariant test — the launcher is a ~300-line reviewable C file with byte-pinned provenance that already migrated away from a Rust dependency; a single-maintainer LGPL Rust binary set is a larger audit surface with weaker provenance. (The unbuilt Windows rung was weighed separately and also [rejected](../feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — landstrip is not battle-tested.)
 - **`hatch-nodejs-version` for Python release versioning**: roughly LOC-neutral (a custom metadata hook replaces the regex), inverts the recorded decision that the dev sentinel never determines a release version, and puts a single-maintainer build plugin in the release supply chain.
 - **YAML consolidation (`js-yaml` vs `yaml`)**: the repo carries both parsers, with the `!!js` tag defined four times on js-yaml (vendored include, app-boot, apps/cli, `scripts/verify-cordis-config.ts`) and twice on `yaml` (sdk-telemetry's `ScalarTag`, sdk-helper's comment-preserving Document editing). The direction is forced — js-yaml cannot replace `yaml` (sdk-helper needs the Document API) — but migrating the js-yaml sites cannot retire the library either (the vendored include pins it) and would put two parsers in charge of one dialect that must agree exactly, against the [personal-config note](../../implemented/feature/2026-07-20-dsh-cli-personal-config.md)'s deliberate load-only-copy parity. Deletable: ~20–25 lines of duplicate tag definitions and two `@types/js-yaml` entries. The consolidation moment is a future include sync, not now.
 

+ 2 - 2
.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md

@@ -46,7 +46,7 @@ Status: rejected — 下列每一项替换在证据上都未达到净简化门
 - **以 `shell-quote` 承担 POSIX 单引号包裹**:两个各 1 行、测试详尽的引号辅助函数,对上一个处于维护模式、有 CVE 历史、转义输出还不一样的包——安全边界不是省一行代码的地方。
 - **以 `strip-ansi` 承担 pty 净化**:pty 净化器是一台流式状态机,带跨分片的断裂序列续接和 OSC `133;D` 提示符标记提取(shell 就绪信号);无状态的剥离器只能替掉约 20 行内层代码,全部状态机构件原样保留。`stripVTControlCharacters` 还被实证会泄漏未终止的 OSC 载荷,会话标题归一化器必须剥除它们(反欺骗)。
 - **以 `pidtree`/`ps-tree` 承担 pty 进程巡检器**:它们只给裸 PID 树;这段代码需要对抗 PID 复用的启动时间身份校验,加上 `/proc` stdin 等待检测,没有包做这些。
-- **以 `execa` 承担 subagent-subprocess 的 dispose(资源释放)阶梯**:`forceKillAfterDelay` 覆盖 SIGTERM→SIGKILL,但覆盖不了先发 stdin EOF 的协作层级,也覆盖不了「无退出沿即 reject」契约;在这里采用它意味着重写各 spawn 调用点、同时阶梯照旧保留。(测试基础设施的 spawn 管线是另一回事——见 [execa 提案](../../proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md)。)
+- **以 `execa` 承担 subagent-subprocess 的 dispose(资源释放)阶梯**:`forceKillAfterDelay` 覆盖 SIGTERM→SIGKILL,但覆盖不了先发 stdin EOF 的协作层级,也覆盖不了「无退出沿即 reject」契约;在这里采用它意味着重写各 spawn 调用点、同时阶梯照旧保留。(测试基础设施的 spawn 管线是另一回事——见 [execa Agent Note](../../implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md)。)
 - **以 `tree-kill` 承担 acp-snapshot 拆除与 lsp 进程终止**:那些代码行做的是排空顺序与错误传播,不是进程树遍历;lsp/bash 已经使用分离的进程组加 taskkill。
 - **在 TUI 测试驱动器上到处使用 node-pty**:[Windows TUI 决策](../../implemented/feature/2026-07-20-windows-tui-support.md)已明确否决在每个宿主上都用 node-pty;它已经是 Windows 那一条腿。
 
@@ -67,7 +67,7 @@ Status: rejected — 下列每一项替换在证据上都未达到净简化门
 - **以 `syncpack`/`manypkg` 替换 `check-workspace-constraints.ts`**:它们只覆盖约 20 行的版本范围对齐;承重的 200+ 行(计算生成的 `files` 列表、cordis peer=dev 配对、层级形状)是仓库政策,没有通用引擎能表达。
 - **以 `remark-validate-links` 替换 `verify-md-links.ts`**:该门禁搭载仓库共享的 mdast 工具链;采用 remark-cli 等于为删掉一个小文件而增加第二套 markdown 技术栈。
 - **以 `prebuildify`/`node-gyp-build` 承担 landlock 启动器打包**:不适用——那些工具通过 dlopen 加载 `.node` addon;这个启动器交付的是独立 exec 的静态二进制,而按平台划分的 `optionalDependencies` 恰恰*就是*二进制分发的生态惯例。
-- **以 `@landstrip/landstrip` 替换 Landlock 启动器本身**:未通过安全不变式检验——启动器是一个约 300 行、可完整评审、来源逐字节锁定的 C 文件,且早已从一个 Rust 依赖迁移出来;单一维护者的 LGPL Rust 二进制集合是更大的审计面加更弱的来源保障。(尚未构建的 Windows 层级是另一个问题——见 [landstrip 评估提案](../../proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)。)
+- **以 `@landstrip/landstrip` 替换 Landlock 启动器本身**:未通过安全不变式检验——启动器是一个约 300 行、可完整评审、来源逐字节锁定的 C 文件,且早已从一个 Rust 依赖迁移出来;单一维护者的 LGPL Rust 二进制集合是更大的审计面加更弱的来源保障。(尚未构建的 Windows 层级经单独权衡后同样被[驳回](../feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——landstrip 未经实战检验。)
 - **以 `hatch-nodejs-version` 承担 Python 发布版本号**:代码行数大致持平(一个自定义 metadata 钩子换掉那个正则),却反转了「dev 哨兵值绝不决定发布版本」这条记录在案的决策,还把一个单一维护者的构建插件放进发布供应链。
 - **YAML 归一(`js-yaml` 与 `yaml`)**:仓库同时携带两个解析器,`!!js` 标签在 js-yaml 上定义了四次(vendor 收录的 include、app-boot、apps/cli、`scripts/verify-cordis-config.ts`),在 `yaml` 上定义了两次(sdk-telemetry 的 `ScalarTag`、sdk-helper 的保留注释式 Document 编辑)。方向是被迫的——js-yaml 无法取代 `yaml`(sdk-helper 需要 Document API)——但迁移 js-yaml 各调用点也退休不了这个库(vendor 收录的 include 锁定了它),还会让两个解析器共管一种必须完全一致的方言,违背[个人配置决策](../../implemented/feature/2026-07-20-dsh-cli-personal-config.md)刻意的「仅加载副本」对等性。可删除的:约 20–25 行重复标签定义和两条 `@types/js-yaml` 条目。归一的时机是未来某次 include 同步,不是现在。
 

+ 5 - 15
.github/workflows/build-exe-for-python-sdk.yml

@@ -124,9 +124,14 @@ jobs:
     steps:
       - uses: actions/checkout@v6
 
+      - uses: pnpm/action-setup@v4
+
+      # setup-node's built-in pnpm store cache keys on platform AND arch, so
+      # the Linux architectures sharing runner.os stay on separate caches.
       - uses: actions/setup-node@v6
         with:
           node-version: 24
+          cache: pnpm
 
       - uses: actions/setup-python@v6
         with:
@@ -135,21 +140,6 @@ jobs:
       - name: Install Python build tooling
         run: python -m pip install uv==0.11.23
 
-      - name: Enable corepack (pnpm)
-        run: corepack enable
-
-      - name: Resolve pnpm store path
-        id: pnpm-store
-        run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
-
-      # Linux architectures share runner.os, so the cache key includes arch.
-      - uses: actions/cache@v4
-        with:
-          path: ${{ steps.pnpm-store.outputs.path }}
-          key: ${{ runner.os }}-${{ runner.arch }}-node-24-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
-          restore-keys: |
-            ${{ runner.os }}-${{ runner.arch }}-node-24-pnpm-
-
       # Cache pkg's target Node binary; lockfile changes roll the
       # exact key while the restore prefix can seed its replacement.
       - uses: actions/cache@v4

+ 120 - 235
.github/workflows/ci.yml

@@ -58,25 +58,33 @@ jobs:
           fetch-depth: 0
           persist-credentials: false
 
+      - uses: pnpm/action-setup@v4
+
+      - uses: actions/setup-node@v6
+        with:
+          node-version: ${{ env.PRIMARY_NODE_VERSION }}
+
+      - name: Configure pnpm store path
+        id: pnpm-store
+        run: |
+          store_root="$HOME/.local/share/pnpm/store"
+          echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
+          store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
+          echo "path=$store_path" >> "$GITHUB_OUTPUT"
+
       # Pull requests consume the default-branch cache but do not put cache
       # compression and upload on the paid latency-critical path. Skipped
       # under failover — see the coverage lane's identical rationale.
       - uses: actions/cache/restore@v4
         if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
         with:
-          path: /home/runner/.local/share/pnpm/store/v11
+          path: ${{ steps.pnpm-store.outputs.path }}
           key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
           restore-keys: |
             ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
 
-      - uses: actions/setup-node@v6
-        with:
-          node-version: ${{ env.PRIMARY_NODE_VERSION }}
-
-      - name: Enable corepack and install dependencies
-        run: |
-          corepack enable
-          pnpm install --frozen-lockfile
+      - name: Install (immutable)
+        run: pnpm install --frozen-lockfile
 
       - name: Run static gates
         env:
@@ -117,24 +125,33 @@ jobs:
         with:
           persist-credentials: false
 
+      - uses: pnpm/action-setup@v4
+
+      - uses: actions/setup-node@v6
+        with:
+          node-version: ${{ env.PRIMARY_NODE_VERSION }}
+
+      - name: Configure pnpm store path
+        id: pnpm-store
+        run: |
+          store_root="$HOME/.local/share/pnpm/store"
+          echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
+          store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
+          echo "path=$store_path" >> "$GITHUB_OUTPUT"
+
       # Skipped under failover: the self-hosted VM's persistent pnpm store
-      # serves warm installs directly, and this hosted-path restore would
-      # spend ~52 s pulling ~180 MB into a path pnpm never reads there.
+      # already serves warm installs, while restoring the hosted archive
+      # would spend ~52 s pulling ~180 MB into that populated store.
       - uses: actions/cache/restore@v4
         if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
         with:
-          path: /home/runner/.local/share/pnpm/store/v11
+          path: ${{ steps.pnpm-store.outputs.path }}
           key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
           restore-keys: |
             ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
 
-      - uses: actions/setup-node@v6
-        with:
-          node-version: ${{ env.PRIMARY_NODE_VERSION }}
-
-      - name: Enable corepack, install dependencies, and prepare bubblewrap
+      - name: Install dependencies and prepare bubblewrap
         run: |
-          corepack enable
           pnpm install --frozen-lockfile &
           install_pid=$!
           bash scripts/prepare-ci-bubblewrap.sh &
@@ -179,15 +196,6 @@ jobs:
       - name: Restore built tree
         run: tar -xzf "$RUNNER_TEMP/node-24-built-tree.tar.gz"
 
-      # Skipped under failover — see the coverage lane's identical rationale.
-      - uses: actions/cache/restore@v4
-        if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
-        with:
-          path: /home/runner/.local/share/pnpm/store/v11
-          key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
-          restore-keys: |
-            ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
-
       - uses: actions/cache/restore@v4
         with:
           path: .cache/eslint
@@ -195,13 +203,31 @@ jobs:
           restore-keys: |
             ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-
 
+      - uses: pnpm/action-setup@v4
+
       - uses: actions/setup-node@v6
         with:
           node-version: ${{ env.PRIMARY_NODE_VERSION }}
 
-      - name: Enable corepack, install dependencies, and prepare bubblewrap
+      - name: Configure pnpm store path
+        id: pnpm-store
+        run: |
+          store_root="$HOME/.local/share/pnpm/store"
+          echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
+          store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
+          echo "path=$store_path" >> "$GITHUB_OUTPUT"
+
+      # Skipped under failover — see the coverage lane's identical rationale.
+      - uses: actions/cache/restore@v4
+        if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
+        with:
+          path: ${{ steps.pnpm-store.outputs.path }}
+          key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
+          restore-keys: |
+            ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
+
+      - name: Install dependencies and prepare bubblewrap
         run: |
-          corepack enable
           pnpm install --frozen-lockfile &
           install_pid=$!
           bash scripts/prepare-ci-bubblewrap.sh &
@@ -277,22 +303,12 @@ jobs:
     steps:
       - uses: actions/checkout@v6
 
+      - uses: pnpm/action-setup@v4
+
       - uses: actions/setup-node@v6
         with:
           node-version: ${{ matrix.node }}
-
-      - name: Enable corepack and resolve pnpm store path
-        id: pnpm-store
-        run: |
-          corepack enable
-          echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
-
-      - uses: actions/cache@v4
-        with:
-          path: ${{ steps.pnpm-store.outputs.path }}
-          key: ${{ runner.os }}-node-${{ matrix.node }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
-          restore-keys: |
-            ${{ runner.os }}-node-${{ matrix.node }}-pnpm-
+          cache: pnpm
 
       - name: Install (immutable)
         run: pnpm install --frozen-lockfile
@@ -323,32 +339,38 @@ jobs:
   # Windows Node under Wine on standard hosted Linux. The master
   # serial-windows job below keeps the complete native-kernel inventory —
   # including the observational portability gates this lane does not run —
-  # on real windows-2025. Direct tool entrypoints stand in for pnpm's cmd
-  # shims, which a Linux-side install does not create; layout, fidelity
-  # limits, and measured timings live in
+  # on real windows-2025. This job only provisions runner state (caches,
+  # apt); scripts/wine-windows-gates.sh owns the gate logic and is the same
+  # script the optional local gate `pnpm run check:windows-wine` runs.
+  # Layout, fidelity limits, and measured timings live in
   # .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md
   windows:
     if: github.event_name == 'pull_request'
     runs-on: ubuntu-latest
     name: windows node 24 / wine blocking
     timeout-minutes: 15
-    env:
-      WINEDEBUG: '-all'
-      WINEARCH: win64
-      # Skip Wine Mono / Gecko installers: Node needs neither.
-      WINEDLLOVERRIDES: 'mscoree,mshtml='
     steps:
       - uses: actions/checkout@v6
         with:
           persist-credentials: false
 
+      - uses: pnpm/action-setup@v4
+
       - uses: actions/setup-node@v6
         with:
           node-version: ${{ env.PRIMARY_NODE_VERSION }}
 
+      - name: Configure pnpm store path
+        id: pnpm-store
+        run: |
+          store_root="$HOME/.local/share/pnpm/store"
+          echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
+          store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
+          echo "path=$store_path" >> "$GITHUB_OUTPUT"
+
       - uses: actions/cache/restore@v4
         with:
-          path: /home/runner/.local/share/pnpm/store/v11
+          path: ${{ steps.pnpm-store.outputs.path }}
           key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
           restore-keys: |
             ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
@@ -365,144 +387,26 @@ jobs:
           path: ~/wine-debs
           key: ${{ steps.wine-cache-key.outputs.key }}
 
-      - name: Install dependencies and provision Wine concurrently
-        run: |
-          corepack enable
-
-          # Windows-lane install-time overrides. supportedArchitectures
-          # additionally materializes the win32-x64 platform packages
-          # (@esbuild/win32-x64, rolldown and rollup MSVC bindings) the
-          # Windows toolchain resolves at runtime; nodeLinker: hoisted lays
-          # node_modules out flat with real files because Windows Node under
-          # Wine does not realpath pnpm's isolated-layout symlinks. Neither
-          # override is recorded in the lockfile, so --frozen-lockfile stays
-          # valid. --ignore-scripts skips Linux lifecycle scripts no gate in
-          # this lane loads; the win32 binaries ship prebuilt.
-          cat >> pnpm-workspace.yaml <<'EOF'
-
-          nodeLinker: hoisted
-          supportedArchitectures:
-            os: [current, win32]
-            cpu: [current, x64]
-          EOF
-
-          pnpm install --frozen-lockfile --ignore-scripts &
-          install_pid=$!
-
-          provision_wine() {
-            set -euo pipefail
-            # Wine from the apt cache when present; else download the full
-            # dependency closure once and keep it for the next run. The
-            # `wine` dispatcher package (not bare `wine64`) is what puts a
-            # binary on PATH.
-            if compgen -G "$HOME/wine-debs/*.deb" > /dev/null; then
-              sudo apt-get install -y --no-install-recommends "$HOME"/wine-debs/*.deb
-            else
-              sudo apt-get update
-              sudo apt-get install -y --no-install-recommends --download-only wine
-              mkdir -p "$HOME/wine-debs"
-              cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/" 2>/dev/null || true
-              sudo apt-get install -y --no-install-recommends wine
-            fi
-            WINE_BIN=''
-            for candidate in "$(command -v wine || true)" "$(command -v wine64 || true)" /usr/lib/wine/wine64; do
-              if [ -n "$candidate" ] && [ -x "$candidate" ]; then WINE_BIN="$candidate"; break; fi
-            done
-            [ -n "$WINE_BIN" ] || { echo '::error::no wine binary found after install'; exit 1; }
-            echo "WINE_BIN=$WINE_BIN" >> "$GITHUB_ENV"
-
-            # Windows Node for the repo's primary line, checksum-verified
-            # against the same dist directory.
-            version=$(curl -fsSL https://nodejs.org/dist/index.json \
-              | jq -r --arg p "v${PRIMARY_NODE_VERSION}." '[.[] | select(.version | startswith($p))][0].version')
-            echo "Windows Node: $version"
-            curl -fsSL -o "$RUNNER_TEMP/node-win.zip" \
-              "https://nodejs.org/dist/${version}/node-${version}-win-x64.zip"
-            curl -fsSL "https://nodejs.org/dist/${version}/SHASUMS256.txt" \
-              | awk -v a="node-${version}-win-x64.zip" '$2 == a { print $1 "  '"$RUNNER_TEMP"'/node-win.zip" }' \
-              | sha256sum --check -
-            unzip -q "$RUNNER_TEMP/node-win.zip" -d "$RUNNER_TEMP/node-win"
-            echo "NODE_WIN=$RUNNER_TEMP/node-win/node-${version}-win-x64/node.exe" >> "$GITHUB_ENV"
-
-            "$WINE_BIN" wineboot --init || true
-            wineserver -w || true
-          }
-          provision_wine &
-          wine_pid=$!
-
-          install_status=0
-          wait "$install_pid" || install_status=$?
-          wine_status=0
-          wait "$wine_pid" || wine_status=$?
-          if (( install_status != 0 )); then exit "$install_status"; fi
-          exit "$wine_status"
-
-      - name: Resolve entrypoints, link vue, smoke Windows Node
+      # Runner provisioning only — a developer machine installs Wine through
+      # its own package manager; the gate script assumes a wine binary and
+      # fails loud without one. Wine from the apt cache when present; else
+      # download the full dependency closure once and keep it for the next
+      # run. The `wine` dispatcher package (not bare `wine64`) is what puts a
+      # binary on PATH.
+      - name: Install Wine
         run: |
-          # Node under Wine cannot attach stdio to the Actions runner's pipes
-          # (Socket open EBADF at bootstrap), so every invocation runs through
-          # this wrapper: stdio to a regular file, replayed after exit.
-          cat > "$RUNNER_TEMP/wine-node.sh" <<'SH'
-          #!/usr/bin/env bash
-          set -u
-          log="$1"; shift
-          "$WINE_BIN" "$NODE_WIN" "$@" < /dev/null > "$log" 2>&1
-          status=$?
-          tail -n 300 "$log"
-          exit "$status"
-          SH
-          chmod +x "$RUNNER_TEMP/wine-node.sh"
-
-          resolve() {
-            local name="$1"; shift
-            for p in "$@"; do
-              if [ -f "$p" ]; then echo "$name=$PWD/$p" >> "$GITHUB_ENV"; return 0; fi
-            done
-            echo "::error::$name not found at any of: $*"; return 1
-          }
-          resolve TSC_JS node_modules/typescript/bin/tsc
-          resolve TSDOWN_JS node_modules/tsdown/dist/run.mjs
-          resolve VITEPRESS_JS website/node_modules/vitepress/bin/vitepress.js node_modules/vitepress/bin/vitepress.js
-
-          # VitePress links vue into the site's node_modules at build time;
-          # Wine cannot CREATE Windows symlinks (ENOTSUP) but follows
-          # pre-existing Unix ones, so lay the link down host-side.
-          if [ -d node_modules/vue ] && [ ! -e website/node_modules/vue ]; then
-            mkdir -p website/node_modules
-            ln -s ../../node_modules/vue website/node_modules/vue
+          if compgen -G "$HOME/wine-debs/*.deb" > /dev/null; then
+            sudo apt-get install -y --no-install-recommends "$HOME"/wine-debs/*.deb
+          else
+            sudo apt-get update
+            sudo apt-get install -y --no-install-recommends --download-only wine
+            mkdir -p "$HOME/wine-debs"
+            cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/" 2>/dev/null || true
+            sudo apt-get install -y --no-install-recommends wine
           fi
 
-          "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/smoke.log" -p "'smoke: ' + process.platform + ' ' + process.arch + ' ' + process.version"
-
-      # The two blocking surfaces run concurrently, the same shape run-gates
-      # gives ci-windows-blocking on native Windows: `build` = tsc -b then
-      # tsdown, `production site` = the VitePress build. Both statuses are
-      # captured so one failure cannot hide the other's result.
-      - name: Run blocking Windows gates concurrently under Wine
-        run: |
-          build_gate() {
-            "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/tsc.log" "$TSC_JS" -b --pretty false || return $?
-            "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/tsdown.log" "$TSDOWN_JS"
-          }
-          site_gate() {
-            cd website
-            "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/site.log" "$VITEPRESS_JS" build .
-          }
-          start=$SECONDS
-          build_gate > "$RUNNER_TEMP/build-gate.out" 2>&1 &
-          build_pid=$!
-          site_gate > "$RUNNER_TEMP/site-gate.out" 2>&1 &
-          site_pid=$!
-          build_status=0
-          wait "$build_pid" || build_status=$?
-          site_status=0
-          wait "$site_pid" || site_status=$?
-          echo "== build gate (exit $build_status, $((SECONDS - start))s elapsed) =="
-          tail -n 120 "$RUNNER_TEMP/build-gate.out"
-          echo "== production site gate (exit $site_status, $((SECONDS - start))s elapsed) =="
-          tail -n 120 "$RUNNER_TEMP/site-gate.out"
-          if (( build_status != 0 )); then exit "$build_status"; fi
-          exit "$site_status"
+      - name: Run the Wine Windows gates
+        run: bash scripts/wine-windows-gates.sh
 
       - name: Shut down wineserver
         if: always()
@@ -550,17 +454,26 @@ jobs:
         with:
           fetch-depth: 2
 
+      - uses: pnpm/action-setup@v4
+
       - uses: actions/setup-node@v6
         with:
           node-version: ${{ env.PRIMARY_NODE_VERSION }}
 
-      - name: Enable corepack and resolve pnpm store path
+      - name: Configure pnpm store path
         id: pnpm-store
         run: |
-          corepack enable
-          echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
+          store_root="$HOME/.local/share/pnpm/store"
+          echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
+          store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
+          echo "path=$store_path" >> "$GITHUB_OUTPUT"
 
       # Master refreshes the caches that pull requests restore without saving.
+      # The store cache stays a hand-rolled actions/cache step rather than
+      # setup-node's `cache: pnpm`: the enterprise pull-request jobs above
+      # restore exactly this key and path, and setup-node's built-in cache
+      # uses its own key format — converting this producer would silently
+      # starve their documented restore-only optimization.
       - uses: actions/cache@v4
         with:
           path: ${{ steps.pnpm-store.outputs.path }}
@@ -618,12 +531,14 @@ jobs:
         with:
           fetch-depth: 0
 
+      - uses: pnpm/action-setup@v4
+
       - uses: actions/setup-node@v6
         with:
           node-version: ${{ env.PRIMARY_NODE_VERSION }}
 
-      - name: Enable corepack (pnpm)
-        run: corepack enable
+      - name: Configure persistent pnpm store
+        run: echo "PNPM_CONFIG_STORE_DIR=$HOME/.local/share/pnpm/store" >> "$GITHUB_ENV"
 
       - name: Install (immutable)
         run: pnpm install --frozen-lockfile
@@ -649,13 +564,12 @@ jobs:
     steps:
       - uses: actions/checkout@v6
 
+      - uses: pnpm/action-setup@v4
+
       - uses: actions/setup-node@v6
         with:
           node-version: ${{ env.PRIMARY_NODE_VERSION }}
 
-      - name: Enable corepack (pnpm)
-        run: corepack enable
-
       - name: Install (immutable)
         run: pnpm install --frozen-lockfile
 
@@ -681,14 +595,12 @@ jobs:
           reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
           /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
 
+      - uses: pnpm/action-setup@v4
+
       - uses: actions/setup-node@v6
         with:
           node-version: ${{ env.PRIMARY_NODE_VERSION }}
 
-      - name: Enable corepack (pnpm)
-        shell: pwsh
-        run: corepack enable
-
       # Master refreshes the small cache that pull requests restore without
       # putting package-store extraction back on the Windows critical path.
       - uses: actions/cache@v4
@@ -775,9 +687,14 @@ jobs:
     steps:
       - uses: actions/checkout@v6
 
+      - uses: pnpm/action-setup@v4
+
+      # The Windows lanes deliberately skip the store cache like the required
+      # windows job; an empty cache input disables setup-node's caching.
       - uses: actions/setup-node@v6
         with:
           node-version: ${{ env.PRIMARY_NODE_VERSION }}
+          cache: ${{ matrix.platform == 'linux' && 'pnpm' || '' }}
 
       - name: Report runner capacity
         run: >-
@@ -785,22 +702,6 @@ jobs:
           console.log(JSON.stringify({ arch: process.arch, cpus: os.cpus().length,
           memoryGiB: Math.round(os.totalmem() / 2 ** 30) }))"
 
-      - name: Enable corepack (pnpm)
-        run: corepack enable
-
-      - name: Resolve pnpm store path
-        if: matrix.platform == 'linux'
-        id: pnpm-store
-        run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
-
-      - uses: actions/cache@v4
-        if: matrix.platform == 'linux'
-        with:
-          path: ${{ steps.pnpm-store.outputs.path }}
-          key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
-          restore-keys: |
-            ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
-
       - name: Install (immutable)
         run: pnpm install --frozen-lockfile
 
@@ -875,9 +776,14 @@ jobs:
     steps:
       - uses: actions/checkout@v6
 
+      - uses: pnpm/action-setup@v4
+
+      # Unlike the larger-runner suite, both platforms cache the store here:
+      # the consolidated topology measures cache mechanics as workload.
       - uses: actions/setup-node@v6
         with:
           node-version: ${{ env.PRIMARY_NODE_VERSION }}
+          cache: pnpm
 
       - name: Report runner capacity
         run: >-
@@ -885,27 +791,6 @@ jobs:
           console.log(JSON.stringify({ arch: process.arch, cpus: os.cpus().length,
           memoryGiB: Math.round(os.totalmem() / 2 ** 30) }))"
 
-      - name: Enable corepack (pnpm)
-        run: corepack enable
-
-      - name: Resolve pnpm store path (Linux)
-        if: matrix.platform == 'linux'
-        id: pnpm-store-linux
-        run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
-
-      - name: Resolve pnpm store path (Windows)
-        if: matrix.platform == 'windows'
-        id: pnpm-store-windows
-        shell: pwsh
-        run: '"path=$(pnpm store path --silent)" >> $env:GITHUB_OUTPUT'
-
-      - uses: actions/cache@v4
-        with:
-          path: ${{ steps.pnpm-store-linux.outputs.path || steps.pnpm-store-windows.outputs.path }}
-          key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
-          restore-keys: |
-            ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
-
       - uses: actions/cache@v4
         if: matrix.platform == 'linux'
         with:

+ 3 - 14
.github/workflows/docs-pages.yml

@@ -33,23 +33,12 @@ jobs:
     steps:
       - uses: actions/checkout@v6
 
+      - uses: pnpm/action-setup@v4
+
       - uses: actions/setup-node@v6
         with:
           node-version: ${{ env.PRIMARY_NODE_VERSION }}
-
-      - name: Enable corepack (pnpm)
-        run: corepack enable
-
-      - name: Resolve pnpm store path
-        id: pnpm-store
-        run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
-
-      - uses: actions/cache@v4
-        with:
-          path: ${{ steps.pnpm-store.outputs.path }}
-          key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
-          restore-keys: |
-            ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
+          cache: pnpm
 
       - name: Install (immutable)
         run: pnpm install --frozen-lockfile

+ 3 - 14
.github/workflows/e2e.yml

@@ -61,23 +61,12 @@ jobs:
     steps:
       - uses: actions/checkout@v6
 
+      - uses: pnpm/action-setup@v4
+
       - uses: actions/setup-node@v6
         with:
           node-version: 24
-
-      - name: Enable corepack (pnpm)
-        run: corepack enable
-
-      - name: Resolve pnpm store path
-        id: pnpm-store
-        run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
-
-      - uses: actions/cache@v4
-        with:
-          path: ${{ steps.pnpm-store.outputs.path }}
-          key: ${{ runner.os }}-node-24-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
-          restore-keys: |
-            ${{ runner.os }}-node-24-pnpm-
+          cache: pnpm
 
       - name: Install (immutable)
         run: pnpm install --frozen-lockfile

+ 3 - 14
.github/workflows/pi-ai-provider-e2e.yml

@@ -27,23 +27,12 @@ jobs:
     steps:
       - uses: actions/checkout@v6
 
+      - uses: pnpm/action-setup@v4
+
       - uses: actions/setup-node@v6
         with:
           node-version: 24
-
-      - name: Enable corepack (pnpm)
-        run: corepack enable
-
-      - name: Resolve pnpm store path
-        id: pnpm-store
-        run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
-
-      - uses: actions/cache@v4
-        with:
-          path: ${{ steps.pnpm-store.outputs.path }}
-          key: ${{ runner.os }}-node-24-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
-          restore-keys: |
-            ${{ runner.os }}-node-24-pnpm-
+          cache: pnpm
 
       - name: Install (immutable)
         run: pnpm install --frozen-lockfile

+ 2 - 3
.github/workflows/sandbox.yml

@@ -53,13 +53,12 @@ jobs:
     steps:
       - uses: actions/checkout@v6
 
+      - uses: pnpm/action-setup@v4
+
       - uses: actions/setup-node@v6
         with:
           node-version: 24
 
-      - name: Enable corepack (pnpm)
-        run: corepack enable
-
       - name: Install (immutable)
         run: pnpm install --frozen-lockfile
 

+ 1 - 0
.gitignore

@@ -29,3 +29,4 @@ python/**/.pytest_cache/
 apps/web/dist/
 .artifacts/
 .playwright-mcp/
+.orig

+ 1 - 0
AGENTS.md

@@ -59,6 +59,7 @@ pnpm run typecheck
 pnpm run lint
 pnpm run duplication    # cross-file TypeScript clone detection
 pnpm run build          # tsc emits lib/types, tsdown bundles runtime
+pnpm run check:windows-wine  # ONLY when diagnosing a known Windows failure (needs wine); CI owns this signal
 pnpm run hygiene        # knip + publint + workspace constraints + NodeNext consumer check
 pnpm run doc-sync       # all documentation gates; leaf list in scripts/run-gates.ts
 pnpm run website:build  # VitePress build (doubles as dead-link check)

+ 12 - 19
apps/cli/tests/built-bin.e2e.ts

@@ -1,7 +1,7 @@
-import { spawn } from 'node:child_process'
 import { existsSync } from 'node:fs'
 import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
+import { execa } from 'execa'
 import { describe, expect, it } from 'vitest'
 
 /**
@@ -22,25 +22,18 @@ import { describe, expect, it } from 'vitest'
 const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
 const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
 
-/** Run the built bin with PIPED stdio; resolve with output + exit code. */
-function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> {
-  return new Promise((resolve, reject) => {
-    const child = spawn(process.execPath, [dshBin], { stdio: ['pipe', 'pipe', 'pipe'] })
-    let stdout = ''
-    let stderr = ''
-    child.stdout.setEncoding('utf8')
-    child.stdout.on('data', (c: string) => { stdout += c })
-    child.stderr.setEncoding('utf8')
-    child.stderr.on('data', (c: string) => { stderr += c })
-    const timer = setTimeout(() => {
-      child.kill('SIGKILL')
-      reject(new Error(`dsh built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`))
-    }, 25_000)
-    // Resolve on `close` (all stdio drained), not `exit`, so captured output is complete.
-    child.on('close', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) })
-    child.on('error', (err) => { clearTimeout(timer); reject(err) })
-    child.stdin.end()
+/** Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output + exit code. */
+async function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> {
+  const result = await execa(process.execPath, [dshBin], {
+    input: '',
+    timeout: 25_000,
+    killSignal: 'SIGKILL',
+    reject: false,
   })
+  if (result.timedOut) {
+    throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
+  }
+  return { stdout: result.stdout, code: result.exitCode ?? -1, stderr: result.stderr }
 }
 
 describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {

+ 3 - 12
apps/web/tests/scaffold.ts

@@ -17,7 +17,7 @@
 // the open llm seam post-boot with installLlmReplay on the settled root ctx
 // (the plugin-row path discards the ReplayHandle; the direct install keeps
 // assertConsumed for the teardown fixture-consumption check).
-import { existsSync, readFileSync } from 'node:fs'
+import { existsSync } from 'node:fs'
 import { mkdtemp, readFile, readdir, realpath, rm, utimes, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join, resolve } from 'node:path'
@@ -73,16 +73,6 @@ const REPLAY_PROVIDERS = [{
   models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 }],
 }]
 
-/** Repo-root .env → process.env for record mode (never overrides set vars); the smoke-real convention. */
-function loadRootEnv(): void {
-  const envPath = join(REPO_ROOT, '.env')
-  if (!existsSync(envPath)) return
-  for (const line of readFileSync(envPath, 'utf8').split('\n')) {
-    const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim())
-    if (m !== null && process.env[m[1]!] === undefined) process.env[m[1]!] = m[2]
-  }
-}
-
 /** A booted web scaffold: real composition, mode-selected model backend, temp world. */
 export interface WebScaffold {
   /** The active snapshot mode this scaffold booted under. */
@@ -146,7 +136,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
   requireDist()
   const mode = webSnapshotMode()
   if (mode === 'record') {
-    loadRootEnv()
+    // Both owning vitest configs (web unconditionally, snapshot in record
+    // mode) load the repo-root .env before this file runs.
     if (process.env.DEEPSEEK_API_KEY === undefined || process.env.DEEPSEEK_API_KEY.length === 0) {
       throw new Error('web e2e record mode needs DEEPSEEK_API_KEY (env or repo-root .env)')
     }

+ 3 - 14
apps/web/tests/smoke-real.e2e.ts

@@ -1,9 +1,9 @@
 // W5 real-host smoke: spawn `dsh web` with a real key, walk the full W5 flow
 // list in a real chromium, screenshot every screen into .artifacts/ for the
 // figma comparison pass. Self-skips without DEEPSEEK_API_KEY (repo e2e
-// convention); the runner loads the repo-root .env explicitly because the CLI
-// only auto-loads .env from its cwd (a temp dir here, so sessions never land
-// in the repo's .sessions).
+// convention); vitest.web.config.ts loads the repo-root .env before this file
+// runs (the CLI only auto-loads .env from its cwd — a temp dir here, so
+// sessions never land in the repo's .sessions).
 //
 // Selector convention: CSS Modules hash as [hash]_[local], so class-substring
 // selectors are unreliable — anchor on data-* attributes (data-variant /
@@ -26,17 +26,6 @@ import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { REPO_ROOT, connectFreshWorkspace, probeFreePort, requireDist, saveFailureShot } from './support.ts'
 
-/** Repo-root .env → process.env (never overrides an already-set variable). */
-function loadRootEnv(): void {
-  const envPath = join(REPO_ROOT, '.env')
-  if (!existsSync(envPath)) return
-  for (const line of readFileSync(envPath, 'utf8').split('\n')) {
-    const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim())
-    if (m !== null && process.env[m[1]!] === undefined) process.env[m[1]!] = m[2]
-  }
-}
-loadRootEnv()
-
 function waitForReadyLine(child: ChildProcess): Promise<string> {
   return new Promise((resolveReady, reject) => {
     let out = ''

+ 21 - 35
examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts

@@ -1,4 +1,3 @@
-import { spawn } from 'node:child_process'
 import { createServer } from 'node:http'
 import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
@@ -6,6 +5,7 @@ import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { promisify } from 'node:util'
 import { zstdDecompress } from 'node:zlib'
+import { execa } from 'execa'
 import { describe, expect, it } from 'vitest'
 
 const binScript = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url))
@@ -69,7 +69,9 @@ describe('jsonrpc-agent keyless smoke', () => {
     await new Promise<void>(resolve => modelServer.listen(0, '127.0.0.1', resolve))
     const address = modelServer.address()
     if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port')
-    const child = spawn(process.execPath, [
+    // The line-predicate protocol driving below is the genuinely custom part;
+    // execa owns spawn, the deadline, and exit settlement around it.
+    const child = execa(process.execPath, [
       '--import',
       'tsx',
       binScript,
@@ -77,27 +79,26 @@ describe('jsonrpc-agent keyless smoke', () => {
     ], {
       cwd: repoRoot,
       env: {
-        ...process.env,
         DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
         DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
         DSH_CWD: root,
         DSH_SESSION_ROOT: join(root, '.sessions'),
         ...(envValue === undefined ? {} : { DSH_MAX_TOKENS_AS_SUCCESS: envValue }),
       },
-      stdio: ['pipe', 'pipe', 'pipe'],
+      timeout: 35_000,
+      killSignal: 'SIGKILL',
+      reject: false,
     })
     const lines: string[] = []
     let stdoutBuffer = ''
     let stderr = ''
-    child.stdout.setEncoding('utf8')
-    child.stdout.on('data', (chunk: string) => {
-      stdoutBuffer += chunk
+    child.stdout.on('data', (chunk: Buffer) => {
+      stdoutBuffer += chunk.toString('utf8')
       const parts = stdoutBuffer.split('\n')
       stdoutBuffer = parts.pop() ?? ''
       lines.push(...parts)
     })
-    child.stderr.setEncoding('utf8')
-    child.stderr.on('data', (chunk: string) => { stderr += chunk })
+    child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
 
     try {
       child.stdin.write(`${JSON.stringify({
@@ -144,16 +145,8 @@ describe('jsonrpc-agent keyless smoke', () => {
       child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`)
       const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr)
       expect(shutdown).toMatchObject({ jsonrpc: '2.0', id: 3, result: {} })
-      if (child.exitCode === null) {
-        await new Promise<void>((resolve, reject) => {
-          child.once('exit', (code) => {
-            if (code === 0) resolve()
-            else reject(new Error(`runtime exited ${code}; stderr=${stderr}`))
-          })
-        })
-      } else {
-        expect(child.exitCode, stderr).toBe(0)
-      }
+      const exit = await child
+      expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0)
       const sessionsRoot = join(root, '.sessions')
       const files = await readdir(sessionsRoot, { recursive: true })
       const log = files.find(file => file.endsWith('.jsonl.zstd'))
@@ -162,14 +155,16 @@ describe('jsonrpc-agent keyless smoke', () => {
       expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd')
       expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session', id: 'main' })
     } finally {
-      if (child.exitCode === null) child.kill('SIGKILL')
+      // No-op after exit; reject: false settles on every outcome, so cleanup never races teardown.
+      child.kill('SIGKILL')
+      await child
       await new Promise<void>(resolve => modelServer.close(() => { resolve() }))
       await rm(root, { recursive: true, force: true })
     }
   }, 40_000)
 
   it('rejects an invalid max-token success env value', async () => {
-    const child = spawn(process.execPath, [
+    const { exitCode, stdout, stderr } = await execa(process.execPath, [
       '--import',
       'tsx',
       binScript,
@@ -177,26 +172,17 @@ describe('jsonrpc-agent keyless smoke', () => {
     ], {
       cwd: repoRoot,
       env: {
-        ...process.env,
         DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
         DSH_MAX_TOKENS_AS_SUCCESS: 'sometimes',
       },
-      stdio: ['ignore', 'pipe', 'pipe'],
-    })
-    let stdout = ''
-    let stderr = ''
-    child.stdout.setEncoding('utf8')
-    child.stdout.on('data', (chunk: string) => { stdout += chunk })
-    child.stderr.setEncoding('utf8')
-    child.stderr.on('data', (chunk: string) => { stderr += chunk })
-
-    const exitCode = await new Promise<number | null>((resolve, reject) => {
-      child.once('error', reject)
-      child.once('exit', resolve)
+      stdin: 'ignore',
+      timeout: 25_000,
+      killSignal: 'SIGKILL',
+      reject: false,
     })
 
     expect(exitCode, stderr).toBe(1)
     expect(stdout).toBe('')
     expect(stderr).toContain('plugin(s) failed to load: @deepseek-ai/dsh-jsonrpc')
-  }, 10_000)
+  }, 30_000)
 })

+ 26 - 29
examples/tui-agent/tests/pty-harness.ts

@@ -1,7 +1,7 @@
-import { spawn } from 'node:child_process'
 import { mkdtemp, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
+import { execa } from 'execa'
 import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
 
 const POSIX_PTY_DRIVER = String.raw`
@@ -94,35 +94,32 @@ async function runPosixPtySmoke(
   options: TuiPtySmokeOptions,
   timeoutMs: number,
 ): Promise<string> {
-  return await new Promise((resolve, reject) => {
-    const child = spawn('python3', [
-      '-c',
-      POSIX_PTY_DRIVER,
-      launch.command,
-      JSON.stringify(launch.args),
-      JSON.stringify(launch.env),
-      cwd,
-      JSON.stringify(options.actions ?? []),
-      String(options.expectedExitCode ?? 0),
-      String(timeoutMs / 1_000),
-    ], { stdio: ['ignore', 'pipe', 'pipe'] })
-    let stdout = ''
-    let stderr = ''
-    child.stdout.setEncoding('utf8')
-    child.stdout.on('data', (chunk: string) => { stdout += chunk })
-    child.stderr.setEncoding('utf8')
-    child.stderr.on('data', (chunk: string) => { stderr += chunk })
-    const timer = setTimeout(() => {
-      child.kill('SIGKILL')
-      reject(new Error(`${options.label} PTY driver did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`))
-    }, timeoutMs + 5_000)
-    child.once('error', (error) => { clearTimeout(timer); reject(error) })
-    child.once('exit', (code) => {
-      clearTimeout(timer)
-      if (code === 0) resolve(stdout)
-      else reject(new Error(`${options.label} PTY driver exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`))
-    })
+  // The driver owns the PTY deadline (`timeoutMs`); the outer execa deadline
+  // only backstops a wedged python3 process itself.
+  const result = await execa('python3', [
+    '-c',
+    POSIX_PTY_DRIVER,
+    launch.command,
+    JSON.stringify(launch.args),
+    JSON.stringify(launch.env),
+    cwd,
+    JSON.stringify(options.actions ?? []),
+    String(options.expectedExitCode ?? 0),
+    String(timeoutMs / 1_000),
+  ], {
+    stdin: 'ignore',
+    timeout: timeoutMs + 5_000,
+    killSignal: 'SIGKILL',
+    reject: false,
+    stripFinalNewline: false,
   })
+  if (result.timedOut) {
+    throw new Error(`${options.label} PTY driver did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
+  }
+  if (result.failed) {
+    throw new Error(`${options.label} PTY driver exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
+  }
+  return result.stdout
 }
 
 async function runWindowsPtySmoke(

+ 1 - 0
knip.json

@@ -13,6 +13,7 @@
     "python/sdk-runtime"
   ],
   "ignoreDependencies": [
+    "@yarnpkg/cli-dist",
     "lightningcss"
   ],
   "workspaces": {

+ 3 - 0
package.json

@@ -40,6 +40,7 @@
     "check:ci:windows-blocking": "tsx scripts/run-gates.ts ci-windows-blocking",
     "check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete",
     "check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational",
+    "check:windows-wine": "bash scripts/wine-windows-gates.sh",
     "check:node-compat": "tsx scripts/run-gates.ts node-compat",
     "knip": "knip --treat-config-hints-as-errors",
     "publint": "tsx scripts/publint-all.ts",
@@ -113,8 +114,10 @@
     "@types/mdast": "^4.0.4",
     "@types/node": "^22.20.0",
     "@vitest/coverage-v8": "^4.1.8",
+    "@yarnpkg/cli-dist": "4.17.1",
     "eslint": "^10.4.1",
     "eslint-plugin-sonarjs": "^4.1.0",
+    "execa": "^10.0.0",
     "fast-check": "^4.8.0",
     "js-yaml": "^4.2.0",
     "jscpd": "^5.0.12",

+ 0 - 590
packages/client/runtime/src/client/sessions/service.ts.orig

@@ -1,590 +0,0 @@
-/**
- * SessionsService: root sessions service — list snapshot store (manager
- * projection; carries `current`, the persisted selection every
- * session-scoped surface keys off — migrated here from ui-layout per the
- * slot-parity design), Agent scope tree (mintScope pattern: no-op plugin
- * Fiber + ctx.extend scope tag; one scope per session, agent id === session
- * id), stable SessionBinding cache, ancestry walk.
- *
- * Scope lifecycle is stage-driven: a scope is minted lazily on first
- * resolution (pure — resolution has no side effects and is render-safe);
- * the event window and deferred teardown key off the STAGED session, which
- * follows `list.current` exactly. Staging is the open signal: the window
- * opens ⟺ the session is on stage (today the stage is `current`; the staged
- * state can widen to a multi-pane list later). A session leaving the list
- * tears its scope down immediately unless it is the staged one, whose scope
- * survives frozen (read-only view) until the stage moves on.
- */
-import type { Context, Fiber } from 'cordis'
-import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
-import type {
-  HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
-} from '@deepseek-ai/dsh-client-ui-slots'
-import type { SnapshotStore } from '../contract/store.ts'
-import { createSnapshotStore } from '../contract/store.ts'
-import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
-import { SessionManager } from './manager.ts'
-import type { SessionListPhase } from './manager.ts'
-import type { Session } from './session.ts'
-
-/** Session list row projected from the host list RPC plus live stream increments. */
-export interface SessionSummary {
-  id: SessionId
-  /** Latest durable log-backed title, absent until the host projects one. */
-  title?: string
-  /** Human-facing label: durable title, project basename, then session id. */
-  displayTitle: string
-  cwd?: string
-  parentId?: SessionId
-  running: boolean
-  /**
-   * Empty-log bit (host summary derivation mirror). List surfaces hide blank
-   * sessions; New Session reuses a blank one targeting the same workspace.
-   * Filtering stays with the consumer — the store carries every row.
-   */
-  blank: boolean
-  updatedAt: number
-}
-
-/**
- * Session list store shape. `current` rides the same snapshot (arbitrated:
- * the single useSessions standard hook reads list and selection together —
- * sidebar highlighting and SessionProvider share one fact source).
- */
-export interface SessionListState {
-  ids: SessionId[]
-  byId: Record<SessionId, SessionSummary>
-  current: SessionId | undefined
-  /** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */
-  phase: SessionListPhase
-}
-
-/** Structured session-create failure. */
-export class SessionCreateError extends Error {
-  override readonly name = 'SessionCreateError'
-
-  /**
-   * @param rpcError - Host business or folded transport error.
-   * @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation.
-   */
-  constructor(
-    readonly rpcError: RpcError,
-    readonly requestedSessionId: SessionId | undefined,
-  ) {
-    super(`session create failed: ${rpcError.code}: ${rpcError.message}`)
-  }
-}
-
-/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
-export interface SessionBinding {
-  readonly sessionId: SessionId
-  readonly session: Session
-  readonly ctx: Context
-}
-
-// Scope primitives live in ../agents/scope.ts (the client mirror of host
-// dsh-scope, keyed by Agent identity); re-exported here so existing
-// consumers keep their import site.
-export { scopeOf } from '../agents/scope.ts'
-
-/**
- * Workspace display title of a session cwd: the path's last non-empty
- * segment (both separators accepted; trailing separators ignored), or ''
- * for separator-only paths — callers own their fallback (session id, raw
- * cwd, default-directory copy). The repo-wide single basename derivation —
- * every surface naming a workspace (picker rows, toggle labels, list titles)
- * calls this instead of re-splitting paths.
- * @param cwd - workspace directory path.
- * @returns basename title, or '' when no non-empty segment exists.
- */
-export function workspaceTitleOf(cwd: string): string {
-  return cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() ?? ''
-}
-
-/**
- * Display title projection: durable title, project directory basename, then
- * the raw id.
- */
-function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string {
-  if (title !== undefined) return title
-  if (cwd !== undefined && cwd !== '') {
-    const base = workspaceTitleOf(cwd)
-    if (base !== '') return base
-  }
-  return id
-}
-
-interface ScopeRecord {
-  fiber: Fiber
-  ctx: Context
-  binding: SessionBinding
-  /** Render-layer standard-props bundle (identity-stable per scope; the renderer's per-info caches key off it). */
-  provideInfo: SessionProvideInfo
-}
-
-/** One plugin's per-session standard-props contribution (see {@link SessionsService.provide}). */
-export interface SessionProvideContribution {
-  /** Bare observable sources, keyed by hook base name ('input' → useInput). */
-  hooks?: Record<string, HostObservable<unknown>>
-  /** Stable plain members (action callbacks etc.), spread into standard props verbatim. */
-  props?: Record<string, unknown>
-}
-
-/**
- * Static declaration plus per-session resolver for one standard-kit
- * contribution. The declared names let the renderer construct the same hook
- * and prop surface while no session is current.
- */
-export interface SessionProvideDescriptor {
-  /** Hook base names (`input` becomes `useInput`). */
-  hooks?: readonly string[]
-  /** Plain standard-prop names. */
-  props?: readonly string[]
-  /** Resolve every declared member for one definite session. */
-  resolve(binding: SessionBinding): SessionProvideContribution
-}
-
-/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
-export class SessionsService {
-  /** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
-  readonly list: SnapshotStore<SessionListState>
-  /** The object-layer instance cluster and frame dispatch entry. */
-  private readonly manager: SessionManager
-
-  /**
-   * Persisted selection cell (the durable half of `list.current`). Private on
-   * purpose: reads go through the list snapshot; writes through {@link
-   * SessionsService.open} / {@link SessionsService.clear}. Projection
-   * validates it against the live list instead of destructively pruning, so a
-   * selection survives transient list states (reconnect re-pull) and
-   * resurfaces when its session returns.
-   */
-  private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
-
-  private readonly scopes = new Map<SessionId, ScopeRecord>()
-  /** Registered per-session standard-props providers, in registration order. */
-  private readonly providers: SessionProvideDescriptor[] = []
-  /** Static no-session projection, rebuilt only when the provider roster changes. */
-  private maybeInfo: SessionMaybeProvideInfo
-  /**
-   * The staged session id — follows `list.current` exactly, holding its last
-   * defined value across masked gaps (a transiently absent selection blanks
-   * `current` without moving the stage, so reconnect re-pulls and removals
-   * keep the staged scope's frozen view alive until the stage moves on).
-   */
-  private watched: SessionId | undefined
-  /** Removed-while-staged sessions whose teardown waits for the stage to move away. */
-  private readonly deferredRemovals = new Set<SessionId>()
-
-  /**
-   * @param ctx - client root context (scope fibers mount under it).
-   * @param api - wire client shared with every Session.
-   */
-  constructor(private readonly rootCtx: Context, api: IApiClient) {
-    this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
-      {},
-      { persist: { name: 'dsh.sessions.current' } })
-    this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId)
-    this.list = createSnapshotStore<SessionListState>({
-      ids: [], byId: {}, current: undefined, phase: 'pending',
-    })
-    // The manager owns wire truth; the store is its projection. Manager
-    // notifications are already microtask-batched.
-    this.manager.subscribe(() => { this.projectList() })
-    // Stage follower: every current write (open() and projection alike)
-    // re-evaluates staging, so startup restore (persisted selection validated
-    // by the projection) and reconnect resurfacing open their window with no
-    // dedicated code path. Safe to run synchronously inside the store notify:
-    // the follower writes no list state — session.open()'s synchronous prefix
-    // touches only session-side state and its own microtask-batched notifier.
-    this.list.subscribe(() => { this.followCurrent() })
-    // The runtime's own contribution comes first: useSession rides the same
-    // provide channel every plugin uses (no renderer special case).
-    this.providers.push({
-      hooks: ['session'],
-      resolve: binding => ({ hooks: { session: binding.session } }),
-    })
-    this.maybeInfo = this.materializeMaybeProvideInfo()
-    rootCtx.reflect.provide('sessions', this, undefined)
-  }
-
-  /**
-   * Register a per-session standard-props provider: every session-scope slot
-   * component receives the contributed members as standard props (`hooks`
-   * sources become `use<Name>` selector hooks on the render side; `props`
-   * spread verbatim). Contributions materialize lazily with the session's
-   * scope record and die with it. Registration order is resolution order;
-   * duplicate member names fail loud at materialization.
-   * @param descriptor - static member roster plus per-session resolver.
-   * @returns disposer removing the provider (already-materialized bundles keep their members until their scope drops).
-   */
-  provide(descriptor: SessionProvideDescriptor): () => void {
-    this.providers.push(descriptor)
-    // Scopes may already exist (boot order: the list lands and resolves
-    // scopes before later plugins register) — their bundles must include
-    // every provider by first render, so re-materialize on roster change.
-    this.rematerializeProvideBundles()
-    return () => {
-      const at = this.providers.indexOf(descriptor)
-      if (at >= 0) this.providers.splice(at, 1)
-      this.rematerializeProvideBundles()
-    }
-  }
-
-  /** Rebuild every live scope's standard-props bundle after a provider roster change. */
-  private rematerializeProvideBundles(): void {
-    this.maybeInfo = this.materializeMaybeProvideInfo()
-    for (const record of this.scopes.values()) {
-      record.provideInfo = this.materializeProvideInfo(record.binding)
-    }
-  }
-
-  /** Build the static no-session kit and reject duplicate declared names. */
-  private materializeMaybeProvideInfo(): SessionMaybeProvideInfo {
-    const hooks: Record<string, undefined> = {}
-    const props: Record<string, undefined> = {}
-    for (const descriptor of this.providers) {
-      for (const name of descriptor.hooks ?? []) {
-        if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
-        hooks[name] = undefined
-      }
-      for (const name of descriptor.props ?? []) {
-        if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
-        props[name] = undefined
-      }
-    }
-    return { sessionId: undefined, hooks, props }
-  }
-
-  /** Materialize the standard-props bundle for one session (fails loud on duplicate member names). */
-  private materializeProvideInfo(binding: SessionBinding): SessionProvideInfo {
-    const hooks: Record<string, HostObservable<unknown>> = {}
-    const props: Record<string, unknown> = {}
-    for (const descriptor of this.providers) {
-      const contribution = descriptor.resolve(binding)
-      const contributedHooks = contribution.hooks ?? {}
-      const contributedProps = contribution.props ?? {}
-      for (const name of Object.keys(contributedHooks)) {
-        if (!(descriptor.hooks ?? []).includes(name)) {
-          throw new Error(`sessions.provide: undeclared hook "${name}"`)
-        }
-      }
-      for (const name of Object.keys(contributedProps)) {
-        if (!(descriptor.props ?? []).includes(name)) {
-          throw new Error(`sessions.provide: undeclared prop "${name}"`)
-        }
-      }
-      for (const name of descriptor.hooks ?? []) {
-        const source = contributedHooks[name]
-        if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`)
-        if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
-        hooks[name] = source
-      }
-      for (const name of descriptor.props ?? []) {
-        if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`)
-        if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
-        props[name] = contributedProps[name]
-      }
-    }
-    return { sessionId: binding.sessionId, hooks, props }
-  }
-
-  /**
-   * Select a session as current. Unknown ids fail loud instead of navigating
-   * nowhere.
-   * @param id - session id (must exist in the list store).
-   */
-  open(id: SessionId): void {
-    this.manager.select(id)
-  }
-
-  /**
-   * Clear the current selection so the layout shows the no-session empty
-   * state (new-session affordance and the workspace preselection flow).
-   * Wipes the persisted selection too — a reload stays on empty until the
-   * user opens or starts a session. The staged scope keeps its frozen view
-   * per the masked-gap contract until the next open() moves the stage.
-   */
-  clear(): void {
-    this.manager.clearSelection()
-  }
-
-  /**
-   * Refresh the real Session baseline, reusing an in-flight pull.
-   * @returns completion of the current or newly started baseline pull.
-   */
-  refresh(): Promise<void> {
-    return this.manager.refreshList()
-  }
-
-  /**
-   * Route a mux stream envelope into the Session object layer.
-   * @param envelope - validated mux stream envelope.
-   */
-  handleMuxEnvelope(envelope: Parameters<SessionManager['handleMuxEnvelope']>[0]): void {
-    this.manager.handleMuxEnvelope(envelope)
-  }
-
-  /**
-   * Route a Host stream envelope into the Session object layer.
-   * @param envelope - validated Host stream envelope.
-   */
-  handleHostEnvelope(envelope: Parameters<SessionManager['handleHostEnvelope']>[0]): void {
-    this.manager.handleHostEnvelope(envelope)
-  }
-
-  /** Rebuild the Session baseline and every opened window after connection. */
-  handleConnected(): void {
-    this.manager.handleConnected()
-  }
-
-  /**
-   * Create a session on the host. Resolution guarantee: by the time the
-   * promise resolves, the created session is in the list store and
-   * {@link SessionsService.binding} resolves it — callers (New Session
-   * draft hand-off) may address the scope synchronously, without waiting a
-   * notifier flush. The synchronous projection below makes this structural
-   * rather than an accident of microtask ordering.
-   * @param opts - target workspace or directory and an optional preallocated id.
-   * @returns the new session id.
-   * @throws {SessionCreateError} with the requested id.
-   */
-  async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise<SessionId> {
-    const result = await this.manager.create(opts)
-    if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId)
-    this.projectList()
-    return result.value.sessionId
-  }
-
-  /**
-   * Resolve an Agent-scoped context view (use-and-discard).
-   * @param id - session id (the agent identity — 1:1 same axis).
-   * @returns scoped ctx, or undefined for a session neither listed nor already scoped.
-   */
-  scope(id: SessionId): Context | undefined {
-    return this.resolve(id)?.ctx
-  }
-
-  /**
-   * Read the Agent scope tag off a context. Service-method seam: fetch
-   * bundles must reach scope resolution through ctx.sessions — a cross-bundle
-   * value import of the standalone helper would inline a second module
-   * instance whose private tag Symbol never matches.
-   * @param ctx - any client context.
-   * @returns the session id, or undefined on root contexts.
-   */
-  scopeOf(ctx: Context): SessionId | undefined {
-    return scopeTagOf(ctx)
-  }
-
-  /**
-   * Resolve the business Session behind an Agent-scoped context — the one
-   * hop every scoped consumer (event listeners, per-session controllers)
-   * takes from ctx-space into object-space (the client mirror of host
-   * `agent.session`). Same service-method seam as
-   * {@link SessionsService.scopeOf}.
-   * @param ctx - an Agent-scoped context.
-   * @returns the Session, or undefined when the ctx is untagged or its scope was pruned.
-   */
-  sessionOf(ctx: Context): Session | undefined {
-    const id = scopeTagOf(ctx)
-    if (id === undefined) return undefined
-    return this.scopes.get(id)?.binding.session
-  }
-
-  /**
-   * Resolve the stable session binding (scope-addressed assembly feed). Pure
-   * resolution — no staging, no window side effects.
-   * @param id - session id.
-   * @returns binding, or undefined for a session neither listed nor already scoped.
-   */
-  binding(id: SessionId): SessionBinding | undefined {
-    return this.resolve(id)?.binding
-  }
-
-  /**
-   * Resolve the render-layer standard-props bundle (SessionProvider's feed
-   * through the renderer host; ctx never enters the render layer). Pure
-   * resolution — render-safe: SessionProvider calls this during render, so no
-   * staging, no window side effects (StrictMode double-invokes and concurrent
-   * discarded passes must stay free).
-   * @param id - session id.
-   * @returns the provide info, or undefined for a session neither listed nor already scoped.
-   */
-  provideInfo(id: string): SessionProvideInfo | undefined {
-    return this.resolve(id as SessionId)?.provideInfo
-  }
-
-  /**
-   * Resolve the current-session-optional standard kit. Unknown or absent ids
-   * return the static no-session projection rather than removing hook props.
-   * @param id - current session id, when selected.
-   * @returns a definite or no-session provide bundle.
-   */
-  maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
-    return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo
-  }
-
-  /**
-   * Move the stage to the list's current session: sweep teardowns deferred
-   * behind the previous occupant and pull the new occupant's history window.
-   * Staging IS the open signal — the window opens ⟺ the session is on stage
-   * — and open() is idempotent (an in-flight or completed open no-ops; a
-   * failed one retries the next time current is touched).
-   */
-  private followCurrent(): void {
-    const snapshot = this.list.getSnapshot()
-    const current = snapshot.current
-    // A masked gap (current blanked while the selection's session is
-    // transiently absent) holds the stage: tearing down on the gap would
-    // destroy exactly the frozen scope the mask exists to preserve.
-    if (current === undefined || snapshot.byId[current] === undefined || current === this.watched) return
-    this.watched = current
-    this.sweepDeferred()
-    const record = this.resolve(current)
-    /* v8 ignore next 3 -- defensive: current is always a listed id (open()
-     * validates and the projection masks absent selections), so resolve
-     * cannot miss; kept so a future current writer cannot crash the notify. */
-    if (record !== undefined) {
-      void record.binding.session.open()
-    }
-  }
-
-  /**
-   * Breadcrumb feed: walk parentId links inside the list store.
-   * @param id - session id.
-   * @returns summaries from root ancestor to the session itself (empty when unknown; a broken link stops the walk).
-   */
-  ancestry(id: SessionId): SessionSummary[] {
-    const { byId } = this.list.getSnapshot()
-    const chain: SessionSummary[] = []
-    let cursor: SessionId | undefined = id
-    while (cursor !== undefined) {
-      const summary: SessionSummary | undefined = byId[cursor]
-      if (summary === undefined || chain.includes(summary)) break
-      chain.unshift(summary)
-      cursor = summary.parentId
-    }
-    return chain
-  }
-
-  /**
-   * Lazily mint the scope + binding for an eligible session. Eligibility and
-   * prune share one predicate (decision 12): listed on the host — a scope is
-   * born when its session enters the client's view (list mirror row from the
-   * baseline pull, a create() echo, or the session-added frame) and dies with
-   * the prune when the row leaves.
-   */
-  private resolve(id: SessionId): ScopeRecord | undefined {
-    const existing = this.scopes.get(id)
-    if (existing !== undefined) return existing
-    if (!this.eligible(id)) return undefined
-    const { fiber, ctx } = createScope(this.rootCtx, id)
-    const session = this.manager.get(id)
-    // The Session owns its scoped dispatch point (host Agent.loopCtx mirror);
-    // mint and bind are one step so a live scope record implies a bound actx.
-    session.bindScope(ctx)
-    const binding: SessionBinding = { sessionId: id, session, ctx }
-    const record: ScopeRecord = {
-      fiber,
-      ctx,
-      binding,
-      // Sources are bare observables; React binds selector hooks at its own seam.
-      provideInfo: this.materializeProvideInfo(binding),
-    }
-    this.scopes.set(id, record)
-    return record
-  }
-
-  /** The one aliveness predicate shared by scope mint and prune: host-listed. */
-  private eligible(id: SessionId): boolean {
-    return this.list.getSnapshot().byId[id] !== undefined
-  }
-
-  /** Project the manager's list snapshot into the store (title derivation is display-only). */
-  private projectList(): void {
-    const { items, current, phase } = this.manager.getListSnapshot()
-    const ids: SessionId[] = []
-    const byId: Record<SessionId, SessionSummary> = {}
-    for (const entry of items) {
-      ids.push(entry.sessionId)
-      byId[entry.sessionId] = {
-        id: entry.sessionId,
-        displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
-        running: entry.running,
-        blank: entry.blank,
-        updatedAt: entry.updatedAt,
-        ...(entry.title !== undefined ? { title: entry.title } : {}),
-        ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
-        ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
-      }
-    }
-    const persisted = this.selection.getSnapshot().sessionId
-    // No current (cleared, or masked gap) wipes the persisted cell — a reload
-    // stays on empty; the in-memory selection still resurfaces a masked id.
-    if (current === undefined) {
-      if (persisted !== undefined) this.selection.set({})
-    } else if (byId[current] !== undefined && persisted !== current) {
-      this.selection.set({ sessionId: current })
-    }
-    this.list.set({ ids, byId, current, phase })
-    this.pruneScopes(byId)
-  }
-
-  /** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */
-  private pruneScopes(byId: Record<SessionId, SessionSummary>): void {
-    void byId
-    for (const [id, record] of this.scopes) {
-      if (this.eligible(id)) continue
-      if (id === this.watched) {
-        this.deferredRemovals.add(id)
-        continue
-      }
-      this.scopes.delete(id)
-      this.deferredRemovals.delete(id)
-      this.dropScope(id, record)
-    }
-  }
-
-  /**
-   * One teardown for the whole per-session axis (decision 12): the scope
-   * fiber (cascading every actx-registered effect: input shell, slash
-   * controller, popup, plugin stores, listeners), the session-keyed slot
-   * stores, and the Session instance itself — the host session log is the
-   * durable truth, a reopen lazily rebuilds and backfills via open().
-   */
-  private dropScope(id: SessionId, record: ScopeRecord): void {
-    void record.fiber.dispose()
-    // Release the Session's dispatch point with the scope it belongs to (a
-    // surviving instance — the live Intent — rebinds when resolve re-mints).
-    record.binding.session.unbindScope()
-    // Optional lookup: slots and sessions are sibling services with no
-    // declared dependency; a slots-less boot (object-layer tests) skips.
-    this.rootCtx.get('slots')?.pruneStoreScope(id)
-    this.manager.drop(id)
-  }
-
-  /** Run deferred teardowns whose session is no longer staged (called when the stage moves). */
-  private sweepDeferred(): void {
-    for (const id of [...this.deferredRemovals]) {
-      /* v8 ignore next -- defensive: only the staged id ever defers, and every
-       * stage move sweeps first, so the set cannot contain the id the stage just
-       * moved to; kept as a guard against future extra sweep call sites. */
-      if (id === this.watched) continue
-      // Eligible again? (A re-added id cancels the deferred teardown.)
-      if (this.eligible(id)) {
-        this.deferredRemovals.delete(id)
-        continue
-      }
-      const record = this.scopes.get(id)
-      this.deferredRemovals.delete(id)
-      /* v8 ignore next -- defensive: prune deletes a scope and its deferral
-       * together, so a deferred id always still owns its record; kept so a
-       * future teardown path cannot double-dispose. */
-      if (record !== undefined) {
-        this.scopes.delete(id)
-        this.dropScope(id, record)
-      }
-    }
-  }
-}

+ 0 - 98
packages/client/ui-workspace/src/client/index.ts.orig

@@ -1,98 +0,0 @@
-/**
- * Workspace plugin, browser half. Two registrations: WorkspaceBrowser fills
- * the sidebar shell's `sidebar.workspaces` hole (the whole browsing region),
- * and WorkspacePicker fills the conversation hero's picker hole
- * (`conversation.hero.workspace` — both hero forms). Both read real Host
- * Workspaces through the global useWorkspaces hook. Export discipline:
- * packages/client/AGENTS.md.
- */
-import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
-import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts'
-import { createWorkspaceViewStore } from './stores.ts'
-import { WorkspaceBrowser } from './WorkspaceBrowser.tsx'
-import { WorkspacePicker } from './WorkspacePicker.tsx'
-
-export type {
-  WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps,
-} from './contract/slots.ts'
-
-/**
- * Required services (cordis fiber inject). The target slots are declared by
- * the ui-sidebar / ui-conversation applies, whose activation order relative
- * to this one is NOT constrained: dshClient.inject edges are informational
- * (loading/prefetch metadata, never apply sequencing) and neither owner
- * provides a waitable service. apply therefore registers via
- * declaration-aware deferral instead of assuming order.
- */
-export const inject = ['slots', 'sessions', 'workspaces']
-
-/**
- * Register the browser and picker once their slot declarations are on the
- * ledger. Inject factories return plain callbacks; data reads use the
- * framework's global hooks.
- * @param ctx - client root context.
- */
-export function apply(ctx: ClientContext): void {
-  const browserInjected = (): WorkspaceBrowserInjected => ({
-    // With a workspace: materialize (reuse-or-create the blank session) and
-    // navigate. Without one: clear the selection — the layout's empty seat
-    // shows the New Session pure view state and the user picks there.
-    startSession: (workspaceId) => {
-      if (workspaceId === undefined) {
-        ctx.sessions.clear()
-        return
-      }
-      void ctx.workspaces.connectWorkspace(workspaceId).then(
-        (sessionId) => { ctx.sessions.open(sessionId) },
-        (reason: unknown) => { console.warn('new session failed:', reason) },
-      )
-    },
-    open: (sessionId) => { ctx.sessions.open(sessionId) },
-    renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
-    insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
-      await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
-    },
-    createWorkspace: input => ctx.workspaces.create(input),
-  })
-  const pickerInjected = (): WorkspacePickerInjected => ({
-    createWorkspace: input => ctx.workspaces.create(input),
-  })
-  // Declaration-aware registration: each owner's declaring apply may activate
-  // after this one (entry activation order is unconstrained), and a register
-  // into an undeclared slot throws. Register once the declaration is on the
-  // ledger; the subscription also re-registers after an HMR collapse
-  // re-declares the slot (the cascade disposed our entry with it).
-  ctx.effect(() => {
-    const registrations = [
-      {
-        name: 'sidebar.workspaces' as const,
-        component: WorkspaceBrowser,
-        register: () => ctx.slots.register(
-          { name: 'sidebar.workspaces', store: createWorkspaceViewStore(), inject: browserInjected },
-          WorkspaceBrowser,
-        ),
-      },
-      {
-        name: 'conversation.hero.workspace' as const,
-        component: WorkspacePicker,
-        register: () => ctx.slots.register(
-          { name: 'conversation.hero.workspace', inject: pickerInjected },
-          WorkspacePicker,
-        ),
-      },
-    ]
-    const disposers = new Map<string, () => void>()
-    const tryRegister = (entry: (typeof registrations)[number]): void => {
-      if (ctx.slots.spec(entry.name) === undefined) return
-      if (ctx.slots.entries(entry.name).some(e => e.component === entry.component)) return
-      disposers.set(entry.name, entry.register())
-    }
-    const unsubscribers = registrations.map(entry =>
-      ctx.slots.subscribe(entry.name, () => { tryRegister(entry) }))
-    for (const entry of registrations) tryRegister(entry)
-    return () => {
-      for (const unsubscribe of unsubscribers) unsubscribe()
-      for (const dispose of disposers.values()) dispose()
-    }
-  }, 'ui-workspace: browser + picker registrations')
-}

+ 0 - 321
packages/client/ui-workspace/src/client/tree.ts.orig

@@ -1,321 +0,0 @@
-/**
- * Derives the workspace browser tree from Host Workspace order and membership.
- * Unassigned Sessions trail under Ungrouped; blank Sessions remain visible.
- */
-import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
-
-/** Group key for Sessions outside every Workspace. */
-export const UNGROUPED_KEY = ''
-
-/** Display label for the ungrouped bucket row. */
-export const UNGROUPED_LABEL = 'Ungrouped'
-
-/** One session node of a group's visible tree (34px row; children render indented one step). */
-export interface SessionNode {
-  id: SessionId
-  title: string
-  /** Visible children, already expansion/search-filtered (empty when folded). */
-  children: readonly SessionNode[]
-  /** The session HAS children in the data (the twist renders even while folded). */
-  hasChildren: boolean
-  expanded: boolean
-  running: boolean
-  updatedAt: number
-}
-
-/** One workspace group section: header row facts + the visible session tree. */
-export interface GroupNode {
-  /** Group key: the workspace id or {@link UNGROUPED_KEY}. */
-  key: string
-  /** Backing Workspace id; absent only for the ungrouped bucket. */
-  workspaceId: WorkspaceId | undefined
-  cwd: string | undefined
-  label: string
-  /** Total visible sessions in the group. */
-  sessionCount: number
-  expanded: boolean
-  /** The group contains the selected session (active folder tint; supplied here so the renderer never scans). */
-  containsCurrent: boolean
-  /** Visible roots (empty while the group is folded). */
-  sessions: readonly SessionNode[]
-}
-
-/** Viewing state consumed by the derivation — the component's local useState arrays, taken as-is. */
-export interface TreeView {
-  expandedProjects: readonly string[]
-  expandedSessions: readonly string[]
-  query: string
-}
-
-interface Group {
-  key: string
-  workspaceId: WorkspaceId | undefined
-  cwd: string | undefined
-  label: string
-  summaries: Map<SessionId, SessionSummary>
-  roots: SessionId[]
-  children: Map<SessionId, SessionId[]>
-}
-
-/**
- * Directory display label: basename of the path (both separators accepted).
- * Ungrouped-bucket fallback for surfaces without a workspace title.
- * @param cwd - directory path, or undefined for the ungrouped bucket.
- * @returns basename, the raw cwd when it has no basename, or the ungrouped label.
- */
-export function projectLabel(cwd: string | undefined): string {
-  if (cwd === undefined || cwd === '') return UNGROUPED_LABEL
-  const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
-  return base !== undefined && base !== '' ? base : cwd
-}
-
-/** Recency comparator: newest first, id as the deterministic tiebreak (ids are unique per group). */
-function byRecency(a: SessionSummary, b: SessionSummary): number {
-  if (b.updatedAt !== a.updatedAt) return b.updatedAt - a.updatedAt
-  return a.id < b.id ? -1 : 1
-}
-
-/** Build one group's parent/child tree from an ordered member list. */
-function buildGroup(
-  key: string,
-  workspaceId: WorkspaceId | undefined,
-  cwd: string | undefined,
-  label: string,
-  members: readonly SessionSummary[],
-  order: 'account' | 'recency',
-): Group {
-  const summaries = new Map(members.map(m => [m.id, m]))
-  const children = new Map<SessionId, SessionId[]>()
-  const roots: SessionSummary[] = []
-  for (const m of members) {
-    // A session is a tree child only when its parent lives in the same
-    // group; cross-group or unknown parents degrade to group roots.
-    if (m.parentId !== undefined && m.parentId !== m.id && summaries.has(m.parentId)) {
-      const kids = children.get(m.parentId)
-      if (kids === undefined) children.set(m.parentId, [m.id])
-      else kids.push(m.id)
-    } else {
-      roots.push(m)
-    }
-  }
-  // Workspace order is the member iteration order (workspace.sessionIds), so
-  // attached groups keep insertion order; Ungrouped sorts by recency.
-  if (order === 'recency') {
-    roots.sort(byRecency)
-    for (const kids of children.values()) {
-      kids.sort((a, b) => {
-        const sa = summaries.get(a)
-        const sb = summaries.get(b)
-        /* v8 ignore next -- unreachable: kid ids are inserted alongside their summaries. */
-        if (sa === undefined || sb === undefined) return 0
-        return byRecency(sa, sb)
-      })
-    }
-  }
-  const rootIds = roots.map(r => r.id)
-  // parentId cycles (host bug) leave members unreachable from any root;
-  // surface them as extra roots — the flatten walk's visited set stops
-  // loops. Each node sits in at most one kids list and roots have no
-  // in-group parent, so the scan pushes every reachable node exactly once.
-  const reachable = new Set<SessionId>(rootIds)
-  const stack = [...rootIds]
-  while (stack.length > 0) {
-    const top = stack.pop()
-    /* v8 ignore next -- unreachable: the loop condition guarantees a non-empty stack. */
-    if (top === undefined) break
-    for (const kid of children.get(top) ?? []) {
-      reachable.add(kid)
-      stack.push(kid)
-    }
-  }
-  for (const m of members) {
-    if (!reachable.has(m.id)) rootIds.push(m.id)
-  }
-  return { key, workspaceId, cwd, label, summaries, roots: rootIds, children }
-}
-
-/**
- * Group Sessions by Host Workspace: one group per entity in stable Host
- * order, with members resolved from sessionIds in their stored order. Sessions
- * outside every Workspace trail in the recency-ordered Ungrouped bucket.
- */
-function groupByWorkspace(list: SessionListState, workspaces: readonly WorkspaceView[]): Group[] {
-  const groups: Group[] = []
-  const accounted = new Set<SessionId>()
-  for (const workspace of workspaces) {
-    const members: SessionSummary[] = []
-    for (const id of workspace.sessionIds) {
-      const summary = list.byId[id]
-      if (summary === undefined) continue // account may lead the list pull; the row appears when the summary lands
-      accounted.add(id)
-      members.push(summary)
-    }
-    groups.push(buildGroup(
-      workspace.workspaceId, workspace.workspaceId, workspace.path, workspace.title, members, 'account',
-    ))
-  }
-  const stray = list.ids
-    .map(id => list.byId[id])
-    .filter((s): s is SessionSummary => s !== undefined && !accounted.has(s.id))
-  if (stray.length > 0) {
-    groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, UNGROUPED_LABEL, stray, 'recency'))
-  }
-  return groups
-}
-
-function sessionNode(s: SessionSummary, children: readonly SessionNode[], hasChildren: boolean, expanded: boolean): SessionNode {
-  return {
-    id: s.id,
-    title: s.displayTitle,
-    children,
-    hasChildren,
-    expanded,
-    running: s.running,
-    updatedAt: s.updatedAt,
-  }
-}
-
-function buildVisible(g: Group, expandedSessions: ReadonlySet<string>): SessionNode[] {
-  const visited = new Set<SessionId>()
-  const walk = (id: SessionId): SessionNode | null => {
-    if (visited.has(id)) return null
-    visited.add(id)
-    const s = g.summaries.get(id)
-    /* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */
-    if (s === undefined) return null
-    const kids = g.children.get(id) ?? []
-    const expanded = expandedSessions.has(id)
-    const children = expanded ? kids.map(walk).filter((n): n is SessionNode => n !== null) : []
-    return sessionNode(s, children, kids.length > 0, expanded)
-  }
-  return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
-}
-
-/** Matched sessions plus their ancestor chains (forced visible under search). */
-function searchVisible(g: Group, q: string): Set<SessionId> {
-  const visible = new Set<SessionId>()
-  for (const m of g.summaries.values()) {
-    if (!m.displayTitle.toLowerCase().includes(q)) continue
-    let cur: SessionSummary | undefined = m
-    while (cur !== undefined && !visible.has(cur.id)) {
-      visible.add(cur.id)
-      cur = cur.parentId !== undefined && cur.parentId !== cur.id ? g.summaries.get(cur.parentId) : undefined
-    }
-  }
-  return visible
-}
-
-function buildSearch(g: Group, visible: ReadonlySet<SessionId>): SessionNode[] {
-  const visited = new Set<SessionId>()
-  const walk = (id: SessionId): SessionNode | null => {
-    if (visited.has(id) || !visible.has(id)) return null
-    visited.add(id)
-    const s = g.summaries.get(id)
-    /* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */
-    if (s === undefined) return null
-    const kids = (g.children.get(id) ?? []).filter(kid => visible.has(kid))
-    const children = kids.map(walk).filter((n): n is SessionNode => n !== null)
-    return sessionNode(s, children, kids.length > 0, kids.length > 0)
-  }
-  return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
-}
-
-/**
- * Derive the nested workspace browser group structure.
- *
- * Normal mode: every group shows; sessions populate under expanded groups,
- * descending only into expanded sessions. Search mode (non-blank query,
- * case-insensitive display-title substring): expansion state is ignored —
- * matched sessions and their ancestor chains are forced visible, groups
- * without a display-title or label hit are dropped, and a label-only hit
- * keeps the bare group header. Blank sessions are excluded everywhere.
- * @param list - sessions list snapshot (`current` feeds containsCurrent).
- * @param workspaces - real workspaces in stable Host order.
- * @param view - local expansion arrays and search query.
- * @returns group sections in render order.
- */
-export function deriveGroups(
-  list: SessionListState,
-  workspaces: readonly WorkspaceView[],
-  view: TreeView,
-): GroupNode[] {
-  const q = view.query.trim().toLowerCase()
-  const expandedProjects = new Set(view.expandedProjects)
-  const expandedSessions = new Set(view.expandedSessions)
-  const currentGroup = list.current === undefined
-    ? undefined
-    : (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined)
-        ?? UNGROUPED_KEY
-  const groups: GroupNode[] = []
-  for (const g of groupByWorkspace(list, workspaces)) {
-    if (q === '') {
-      const expanded = expandedProjects.has(g.key)
-      groups.push({
-        key: g.key,
-        workspaceId: g.workspaceId,
-        cwd: g.cwd,
-        label: g.label,
-        sessionCount: g.summaries.size,
-        expanded,
-        containsCurrent: g.key === currentGroup,
-        sessions: expanded ? buildVisible(g, expandedSessions) : [],
-      })
-    } else {
-      const visible = searchVisible(g, q)
-      if (visible.size === 0 && !g.label.toLowerCase().includes(q)) continue
-      groups.push({
-        key: g.key,
-        workspaceId: g.workspaceId,
-        cwd: g.cwd,
-        label: g.label,
-        sessionCount: g.summaries.size,
-        expanded: visible.size > 0,
-        containsCurrent: g.key === currentGroup,
-        sessions: buildSearch(g, visible),
-      })
-    }
-  }
-  return groups
-}
-
-/**
- * Derive the flat session list ("In one list" mode): every session — fork
- * children included — as a top-level row, strictly newest-first. No grouping,
- * no parent/child adjacency; rows reuse SessionNode with children always
- * empty so the renderer stays branch-free. Search mode filters by
- * case-insensitive display-title substring.
- * @param list - sessions list snapshot.
- * @param view - the search query (expansion state does not apply).
- * @returns flat rows in render order.
- */
-export function deriveFlat(list: SessionListState, view: Pick<TreeView, 'query'>): SessionNode[] {
-  const q = view.query.trim().toLowerCase()
-  const rows: SessionSummary[] = []
-  for (const id of list.ids) {
-    const s = list.byId[id]
-    if (s === undefined) continue
-    if (q !== '' && !s.displayTitle.toLowerCase().includes(q)) continue
-    rows.push(s)
-  }
-  rows.sort(byRecency)
-  return rows.map(s => sessionNode(s, [], false, false))
-}
-
-/**
- * Compact relative time for session rows ("now", "5min", "3h", "2d", "4mo", "1y").
- * @param updatedAt - epoch ms of the session's last activity.
- * @param now - current epoch ms (injected for pure rendering).
- * @returns the row's trailing time label.
- */
-export function formatRelativeTime(updatedAt: number, now: number): string {
-  const MIN = 60_000
-  const HOUR = 3_600_000
-  const DAY = 86_400_000
-  const diff = Math.max(0, now - updatedAt)
-  if (diff < MIN) return 'now'
-  if (diff < HOUR) return `${Math.floor(diff / MIN)}min`
-  if (diff < DAY) return `${Math.floor(diff / HOUR)}h`
-  if (diff < 30 * DAY) return `${Math.floor(diff / DAY)}d`
-  if (diff < 365 * DAY) return `${Math.floor(diff / (30 * DAY))}mo`
-  return `${Math.floor(diff / (365 * DAY))}y`
-}

+ 8 - 7
packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts

@@ -1,7 +1,7 @@
-import { spawn } from 'node:child_process'
 import { existsSync } from 'node:fs'
 import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
+import { execa } from 'execa'
 import { describe, expect, it } from 'vitest'
 
 /**
@@ -36,12 +36,13 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
       console.log(JSON.stringify(result))
       process.exit(0)
     `
-    const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] })
-    let stdout = ''
-    let stderr = ''
-    child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
-    child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
-    const exitCode = await new Promise<number | null>(resolve => child.on('close', resolve))
+    const { exitCode, stdout, stderr } = await execa(process.execPath, ['--input-type=module', '-e', script], {
+      cwd: pkgDir,
+      stdin: 'ignore',
+      timeout: 55_000,
+      killSignal: 'SIGKILL',
+      reject: false,
+    })
 
     expect(exitCode, `stderr:\n${stderr}`).toBe(0)
     const lastLine = stdout.trim().split('\n').at(-1) ?? ''

+ 15 - 20
packages/examples/acp-demo/tests/built-bin.e2e.ts

@@ -17,6 +17,7 @@ import {
 import { Readable, Writable } from 'node:stream'
 import { promisify } from 'node:util'
 import { zstdDecompress } from 'node:zlib'
+import { execa } from 'execa'
 import { afterEach, describe, expect, it } from 'vitest'
 
 /**
@@ -209,25 +210,19 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, n
   }, 30_000)
 })
 
-/** Spawn the built acp bin against `configArg` and resolve with its exit code + stderr. */
-function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> {
-  return new Promise((resolve, reject) => {
-    const proc = spawn(process.execPath, [acpBin, '--config', configArg], {
-      cwd,
-      env: {
-        ...process.env,
-        DSH_HOME: join(cwd, '.dsh'),
-        DSH_AGENTS_HOME: join(cwd, '.agents'),
-      },
-      stdio: ['pipe', 'pipe', 'pipe'],
-    })
-    child = proc
-    let stderr = ''
-    proc.stderr.setEncoding('utf8')
-    proc.stderr.on('data', (c: string) => { stderr += c })
-    const timer = setTimeout(() => { proc.kill('SIGKILL'); reject(new Error(`bin did not exit within 25s. stderr:\n${stderr}`)) }, 25_000)
-    proc.on('exit', (code) => { clearTimeout(timer); resolve({ code: code ?? -1, stderr }) })
-    proc.on('error', (err) => { clearTimeout(timer); reject(err) })
-    proc.stdin.end()
+/** Spawn the built acp bin against `configArg` (stdin closed at EOF) and resolve with its exit code + stderr. */
+async function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> {
+  const result = await execa(process.execPath, [acpBin, '--config', configArg], {
+    cwd,
+    env: {
+      DSH_HOME: join(cwd, '.dsh'),
+      DSH_AGENTS_HOME: join(cwd, '.agents'),
+    },
+    input: '',
+    timeout: 25_000,
+    killSignal: 'SIGKILL',
+    reject: false,
   })
+  if (result.timedOut) throw new Error(`bin did not exit within 25s. stderr:\n${result.stderr}`)
+  return { code: result.exitCode ?? -1, stderr: result.stderr }
 }

+ 25 - 27
packages/examples/cli-demo/tests/built-bin.e2e.ts

@@ -1,4 +1,3 @@
-import { spawn } from 'node:child_process'
 import { existsSync } from 'node:fs'
 import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
@@ -6,6 +5,7 @@ import { dirname, join } from 'node:path'
 import { promisify } from 'node:util'
 import { fileURLToPath } from 'node:url'
 import { zstdDecompress } from 'node:zlib'
+import { execa } from 'execa'
 import { afterEach, describe, expect, it } from 'vitest'
 
 /**
@@ -114,36 +114,34 @@ interface BinResult {
   readonly stderr: string
 }
 
-function runBuiltBin(cwd: string, args: readonly string[], interrupt?: NodeJS.Signals): Promise<BinResult> {
-  return new Promise((resolveResult, reject) => {
-    const child = spawn(process.execPath, [cliBin, ...args], {
-      cwd,
-      env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
-      stdio: ['ignore', 'pipe', 'pipe'],
-    })
-    let stdout = ''
-    let stderr = ''
+async function runBuiltBin(cwd: string, args: readonly string[], interrupt?: NodeJS.Signals): Promise<BinResult> {
+  const subprocess = execa(process.execPath, [cliBin, ...args], {
+    cwd,
+    env: { DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
+    stdin: 'ignore',
+    timeout: 25_000,
+    killSignal: 'SIGKILL',
+    reject: false,
+    stripFinalNewline: false,
+  })
+  // Genuinely custom mid-stream logic: the signal cases deliver `interrupt`
+  // once the first streamed chunk proves the turn is in flight.
+  if (interrupt !== undefined) {
+    let streamed = ''
     let interrupted = false
-    child.stdout.setEncoding('utf8')
-    child.stdout.on('data', (chunk: string) => {
-      stdout += chunk
-      if (interrupt !== undefined && !interrupted && stdout.includes('assistant/chunk')) {
+    subprocess.stdout.on('data', (chunk: Buffer) => {
+      streamed += chunk.toString('utf8')
+      if (!interrupted && streamed.includes('assistant/chunk')) {
         interrupted = true
-        child.kill(interrupt)
+        subprocess.kill(interrupt)
       }
     })
-    child.stderr.setEncoding('utf8')
-    child.stderr.on('data', (chunk: string) => { stderr += chunk })
-    const timer = setTimeout(() => {
-      child.kill('SIGKILL')
-      reject(new Error(`built CLI did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`))
-    }, 25_000)
-    child.once('error', (error) => { clearTimeout(timer); reject(error) })
-    child.once('exit', (code, signal) => {
-      clearTimeout(timer)
-      resolveResult({ code: code ?? -1, signal, stdout, stderr })
-    })
-  })
+  }
+  const result = await subprocess
+  if (result.timedOut) {
+    throw new Error(`built CLI did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
+  }
+  return { code: result.exitCode ?? -1, signal: result.signal ?? null, stdout: result.stdout, stderr: result.stderr }
 }
 
 let consumer: string | undefined

+ 8 - 7
packages/lsp/lsp-local/tests/built-lib.e2e.ts

@@ -1,9 +1,9 @@
-import { spawn } from 'node:child_process'
 import { existsSync } from 'node:fs'
 import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { fileURLToPath, pathToFileURL } from 'node:url'
+import { execa } from 'execa'
 import { afterAll, beforeAll, describe, expect, it } from 'vitest'
 
 /**
@@ -57,12 +57,13 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
       console.log(JSON.stringify(result))
       await ctx.fiber.dispose()
     `
-    const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] })
-    let stdout = ''
-    let stderr = ''
-    child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
-    child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
-    const exitCode = await new Promise<number | null>(resolve => child.on('close', resolve))
+    const { exitCode, stdout, stderr } = await execa(process.execPath, ['--input-type=module', '-e', script], {
+      cwd: pkgDir,
+      stdin: 'ignore',
+      timeout: 55_000,
+      killSignal: 'SIGKILL',
+      reject: false,
+    })
 
     expect(exitCode, `stderr:\n${stderr}`).toBe(0)
     const lastLine = stdout.trim().split('\n').at(-1) ?? ''

+ 2 - 0
packages/sdk/create-sdk/tests/link-workspace.e2e.ts

@@ -90,6 +90,8 @@ describe.skipIf(!existsSync(builtScripts))('live-linked generated projects', ()
         XDG_DATA_HOME: join(cacheRoot, 'data'),
         npm_config_cache: join(cacheRoot, 'npm'),
         ...pnpmStore === undefined ? {} : { pnpm_config_store_dir: pnpmStore },
+        // A generated project has no lockfile yet; ambient CI must not make its first Yarn install immutable.
+        ...name === 'yarn' ? { YARN_ENABLE_IMMUTABLE_INSTALLS: 'false' } : {},
       }
       await execFileAsync(name, manager.installCommand(), {
         cwd: root,

+ 26 - 29
packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts

@@ -1,10 +1,10 @@
-import { spawn } from 'node:child_process'
 import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
+import { execa } from 'execa'
 import { Context } from 'cordis'
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
 import SessionStore, {
   SessionId, TOOL_OUTCOME_UNKNOWN,
   type SessionEvent,
@@ -19,22 +19,21 @@ const roots: string[] = []
 const CHILD_FAILPOINT_TIMEOUT_MS = 30_000
 
 async function waitForMarker(path: string, expected: string): Promise<string> {
-  const deadline = Date.now() + CHILD_FAILPOINT_TIMEOUT_MS
-  for (;;) {
-    try {
-      const content = await readFile(path, 'utf8')
-      if (content === expected) return content
-      if (!expected.startsWith(content)) {
-        throw new Error(`crash child wrote unexpected failpoint ${JSON.stringify(content)}`)
-      }
-    } catch (error: unknown) {
+  // vi.waitFor retries every callback throw, so terminal states RESOLVE out
+  // of the retry loop (complete marker, or content that can no longer become
+  // the expected marker) and only the still-in-progress states throw-to-retry.
+  const content = await vi.waitFor(async () => {
+    const current = await readFile(path, 'utf8').catch((error: unknown) => {
       if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
-    }
-    if (Date.now() >= deadline) {
-      throw new Error(`crash child did not publish failpoint ${JSON.stringify(expected)} at ${path}`)
-    }
-    await new Promise(resolve => setTimeout(resolve, 10))
+      throw new Error(`crash child did not publish failpoint ${JSON.stringify(expected)} at ${path}`, { cause: error })
+    })
+    if (current === expected || !expected.startsWith(current)) return current
+    throw new Error(`crash child has not finished publishing failpoint ${JSON.stringify(expected)}`)
+  }, { interval: 10, timeout: CHILD_FAILPOINT_TIMEOUT_MS })
+  if (content !== expected) {
+    throw new Error(`crash child wrote unexpected failpoint ${JSON.stringify(content)}`)
   }
+  return content
 }
 
 async function crashAt(mode: 'request' | 'tool'): Promise<{ root: string; markerText: string }> {
@@ -44,26 +43,24 @@ async function crashAt(mode: 'request' | 'tool'): Promise<{ root: string; marker
   // Keep the open-before-write window deterministic: readiness is marker content, not path existence.
   await writeFile(marker, '')
   const expectedMarker = mode === 'request' ? 'request-dispatched' : 'tool-side-effect'
-  const child = spawn(process.execPath, ['--import', tsxLoader, childScript, mode, root, marker], {
+  // The SIGKILL-at-failpoint choreography stays custom: the child must die
+  // mid-write, so no timeout or graceful termination may reach it first.
+  const child = execa(process.execPath, ['--import', tsxLoader, childScript, mode, root, marker], {
     cwd: repoRoot,
-    env: { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') },
-    stdio: ['ignore', 'ignore', 'pipe'],
+    env: { TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') },
+    stdin: 'ignore',
+    stdout: 'ignore',
+    reject: false,
   })
-  let stderr = ''
-  child.stderr.setEncoding('utf8')
-  child.stderr.on('data', (chunk: string) => { stderr += chunk })
   try {
     const markerText = await waitForMarker(marker, expectedMarker)
-    const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
-      child.once('close', (code, signal) => { resolve({ code, signal }) })
-    })
     child.kill('SIGKILL')
-    const exit = await closed
-    expect(exit).toEqual({ code: null, signal: 'SIGKILL' })
+    const exit = await child
+    expect({ code: exit.exitCode ?? null, signal: exit.signal ?? null }).toEqual({ code: null, signal: 'SIGKILL' })
     return { root, markerText }
   } catch (error: unknown) {
-    if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL')
-    throw new Error(`crash child failed: ${stderr}`, { cause: error })
+    child.kill('SIGKILL')
+    throw new Error(`crash child failed: ${(await child).stderr}`, { cause: error })
   }
 }
 

+ 2 - 2
packages/support/acp-snapshot/README.i18n.yaml

@@ -2,5 +2,5 @@
 # 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
-README.md: d35872e5bb06be88dc5999bfa1800083b2fbbf3c
-README.zh.md: a706b6db5408538c578cb2a1cfc3aa99804a930d
+README.md: d22e6e2d95a1ed930a7f4876daf4b06e2f761f7a
+README.zh.md: 514b7ebfe02cb34ed559633a0fd82cf6194fa4b3

+ 1 - 1
packages/support/acp-snapshot/README.md

@@ -57,7 +57,7 @@ Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canon
 
 The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and each pin's prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md).
 
-Constraints: `suite.ts` imports vitest, so the package entry is importable only inside a vitest run (the launcher, harness, and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the TUI snapshot suite and the web browser e2e lane. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run.
+Constraints: `suite.ts` and `harness.ts` import vitest (the harness polls its durable-boundary waits through `vi.waitFor`), so the package entry is importable only inside a vitest run (the launcher and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the TUI snapshot suite and the web browser e2e lane. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run.
 
 ## Model Experience
 

+ 1 - 1
packages/support/acp-snapshot/README.zh.md

@@ -57,7 +57,7 @@ defineAcpSnapshotSuite({
 
 示例还发布 `cordis.snapshot.yml` 回放 overlay,位于 `cordis.yml` 旁边(bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM,并重写已记录场景的模型 fixture;`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay,并从已提交模型脚本重写 stdout、可比较会话日志预期输出,以及每个 pin 的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。
 
-约束:`suite.ts` 导入 vitest,因此包入口只能在 vitest 运行中导入(启动器、harness 和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP,启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 TUI 快照套件和 web 浏览器 e2e lane 消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once`、`reject_once`等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。
+约束:`suite.ts` 与 `harness.ts` 导入 vitest(harness 通过 `vi.waitFor` 轮询其持久边界等待),因此包入口只能在 vitest 运行中导入(启动器和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP,启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 TUI 快照套件和 web 浏览器 e2e lane 消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once`、`reject_once`等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。
 
 ## 模型体验
 

+ 22 - 19
packages/support/acp-snapshot/src/harness.ts

@@ -21,7 +21,7 @@ import { existsSync, realpathSync } from 'node:fs'
 import { createHash } from 'node:crypto'
 import { tmpdir } from 'node:os'
 import { basename, dirname, join, delimiter } from 'node:path'
-import { setTimeout as delay } from 'node:timers/promises'
+import { vi } from 'vitest'
 import {
   ClientSideConnection,
   PROTOCOL_VERSION,
@@ -457,17 +457,25 @@ async function waitForPersistedTurnStart(
   timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
   minimumTurn?: number,
 ): Promise<void> {
-  const deadline = Date.now() + timeoutMs
-  while (true) {
+  let invalidRecord: { error: unknown } | undefined
+  await vi.waitFor(async () => {
     const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId)
-    const openTurn = log === undefined ? undefined : latestOpenTurn(log.content)
-    if (openTurn !== undefined && (minimumTurn === undefined || openTurn >= minimumTurn)) return
-    if (Date.now() >= deadline) {
+    let openTurn: number | undefined
+    try {
+      openTurn = log === undefined ? undefined : latestOpenTurn(log.content)
+    } catch (error) {
+      // A malformed persisted record is a scenario bug, not a not-yet state:
+      // vi.waitFor retries every callback throw, so capture the validation
+      // failure, resolve the wait, and rethrow immediately below.
+      invalidRecord = { error }
+      return
+    }
+    if (openTurn === undefined || (minimumTurn !== undefined && openTurn < minimumTurn)) {
       const detail = minimumTurn === undefined ? 'turn/start' : `turn/start at or beyond turn ${minimumTurn}`
       throw new Error(`snapshot-harness: session "${sessionId}" did not persist ${detail} within ${timeoutMs}ms`)
     }
-    await delay(WAIT_POLL_INTERVAL_MS)
-  }
+  }, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
+  if (invalidRecord !== undefined) throw invalidRecord.error
 }
 
 /**
@@ -481,15 +489,12 @@ async function waitForPersistedTurnEnd(
   sessionId: string,
   timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
 ): Promise<void> {
-  const deadline = Date.now() + timeoutMs
-  while (true) {
+  await vi.waitFor(async () => {
     const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId)
-    if (log !== undefined && latestTurnIsClosed(log.content)) return
-    if (Date.now() >= deadline) {
+    if (log === undefined || !latestTurnIsClosed(log.content)) {
       throw new Error(`snapshot-harness: session "${sessionId}" did not persist turn/end within ${timeoutMs}ms`)
     }
-    await delay(WAIT_POLL_INTERVAL_MS)
-  }
+  }, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
 }
 
 /** Wait for a cwd-relative marker proving an external action reached readiness. */
@@ -499,13 +504,11 @@ async function waitForWorkspaceFile(
   timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
 ): Promise<void> {
   const target = join(cwd, path)
-  const deadline = Date.now() + timeoutMs
-  while (!existsSync(target)) {
-    if (Date.now() >= deadline) {
+  await vi.waitFor(() => {
+    if (!existsSync(target)) {
       throw new Error(`snapshot-harness: workspace file "${path}" did not appear within ${timeoutMs}ms`)
     }
-    await delay(WAIT_POLL_INTERVAL_MS)
-  }
+  }, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
 }
 
 /** Return whether the last complete raw-JSONL turn boundary closes its turn. */

+ 51 - 60
packages/support/llm-mock-server/src/cli.ts

@@ -3,6 +3,7 @@
  * @module @deepseek-ai/dsh-llm-mock-server/cli
  */
 
+import { parseArgs } from 'node:util'
 import { MAX_MOCK_LLM_TIMER_DELAY_MS, MOCK_LLM_BEHAVIORS } from './index.ts'
 import type {
   ConcreteMockLlmBehavior,
@@ -63,14 +64,6 @@ Other:
   --help
 `
 
-function optionValue(argv: readonly string[], index: number, option: string): string {
-  const value = argv[index + 1]
-  if (value === undefined || value.startsWith('--')) {
-    throw new Error(`dsh-llm-mock-server: ${option} requires a value`)
-  }
-  return value
-}
-
 function numberValue(option: string, value: string): number {
   const parsed = Number(value)
   if (!Number.isFinite(parsed)) throw new Error(`dsh-llm-mock-server: ${option} must be a finite number`)
@@ -122,66 +115,64 @@ function parseRandomWeights(raw: string): MockLlmRandomWeights {
   return weights
 }
 
+/** parseArgs vocabulary: every documented flag; only `--repeat-last` and `--help` are boolean. */
+const CLI_OPTIONS = {
+  'sequence': { type: 'string' },
+  'host': { type: 'string' },
+  'port': { type: 'string' },
+  'api-key': { type: 'string' },
+  'listen-delay-ms': { type: 'string' },
+  'repeat-last': { type: 'boolean' },
+  'seed': { type: 'string' },
+  'random-weights': { type: 'string' },
+  'success-text': { type: 'string' },
+  'partial-text': { type: 'string' },
+  'reasoning-text': { type: 'string' },
+  'chunk-size': { type: 'string' },
+  'chunk-delay-ms': { type: 'string' },
+  'disconnect-delay-ms': { type: 'string' },
+  'retry-after-ms': { type: 'string' },
+  'request-id': { type: 'string' },
+  'tool-name': { type: 'string' },
+  'tool-arguments': { type: 'string' },
+} as const
+
 /**
  * Parse standalone server arguments without starting a process or listener.
+ * Tokenizing rides `node:util` `parseArgs` (strict, no positionals); numeric
+ * coercion, bounds, and cross-option constraints remain manual below it.
  * @param argv - arguments after the executable name.
  * @returns help or validated run configuration.
  */
 export function parseMockLlmCliArgs(argv: readonly string[]): MockLlmCliParseResult {
   if (argv.includes('--help')) return { kind: 'help' }
 
-  let sequenceRaw: string | undefined
-  let host: string | undefined
-  let port = 8_000
-  let apiKey: string | undefined
-  let listenDelayMs: number | undefined
-  let repeatLast = false
-  let randomSeed: number | undefined
-  let randomWeights: MockLlmRandomWeights | undefined
-  let successText: string | undefined
-  let partialText: string | undefined
-  let reasoningText: string | undefined
-  let chunkSize: number | undefined
-  let chunkDelayMs: number | undefined
-  let disconnectDelayMs: number | undefined
-  let retryAfterMs: number | undefined
-  let requestId: string | undefined
-  let toolName: string | undefined
-  let toolArguments: string | undefined
-
-  for (let index = 0; index < argv.length; index += 1) {
-    const option = argv[index] as string
-    if (option === '--repeat-last') {
-      repeatLast = true
-      continue
-    }
-    const value = optionValue(argv, index, option)
-    index += 1
-    switch (option) {
-      case '--sequence': sequenceRaw = value; break
-      case '--host': host = value; break
-      case '--port': port = numberValue(option, value); break
-      case '--api-key': apiKey = value; break
-      case '--listen-delay-ms':
-        listenDelayMs = boundedIntegerValue(option, value, 0, MAX_MOCK_LLM_TIMER_DELAY_MS)
-        break
-      case '--seed': randomSeed = numberValue(option, value); break
-      case '--random-weights': randomWeights = parseRandomWeights(value); break
-      case '--success-text': successText = value; break
-      case '--partial-text': partialText = value; break
-      case '--reasoning-text': reasoningText = value; break
-      case '--chunk-size': chunkSize = numberValue(option, value); break
-      case '--chunk-delay-ms': chunkDelayMs = numberValue(option, value); break
-      case '--disconnect-delay-ms': disconnectDelayMs = numberValue(option, value); break
-      case '--retry-after-ms': retryAfterMs = numberValue(option, value); break
-      case '--request-id': requestId = value; break
-      case '--tool-name': toolName = value; break
-      case '--tool-arguments': toolArguments = value; break
-      default: throw new Error(`dsh-llm-mock-server: unknown option ${JSON.stringify(option)}`)
-    }
-  }
-
-  if (sequenceRaw === undefined) throw new Error('dsh-llm-mock-server: --sequence is required')
+  const { values } = parseArgs({ args: [...argv], options: CLI_OPTIONS, strict: true, allowPositionals: false })
+
+  const host = values.host
+  const port = values.port === undefined ? 8_000 : numberValue('--port', values.port)
+  const apiKey = values['api-key']
+  const listenDelayMs = values['listen-delay-ms'] === undefined
+    ? undefined
+    : boundedIntegerValue('--listen-delay-ms', values['listen-delay-ms'], 0, MAX_MOCK_LLM_TIMER_DELAY_MS)
+  const repeatLast = values['repeat-last'] ?? false
+  const randomSeed = values.seed === undefined ? undefined : numberValue('--seed', values.seed)
+  const randomWeights = values['random-weights'] === undefined ? undefined : parseRandomWeights(values['random-weights'])
+  const successText = values['success-text']
+  const partialText = values['partial-text']
+  const reasoningText = values['reasoning-text']
+  const chunkSize = values['chunk-size'] === undefined ? undefined : numberValue('--chunk-size', values['chunk-size'])
+  const chunkDelayMs = values['chunk-delay-ms'] === undefined ? undefined : numberValue('--chunk-delay-ms', values['chunk-delay-ms'])
+  const disconnectDelayMs = values['disconnect-delay-ms'] === undefined
+    ? undefined
+    : numberValue('--disconnect-delay-ms', values['disconnect-delay-ms'])
+  const retryAfterMs = values['retry-after-ms'] === undefined ? undefined : numberValue('--retry-after-ms', values['retry-after-ms'])
+  const requestId = values['request-id']
+  const toolName = values['tool-name']
+  const toolArguments = values['tool-arguments']
+
+  if (values.sequence === undefined) throw new Error('dsh-llm-mock-server: --sequence is required')
+  const sequenceRaw = values.sequence
   const parsedSequence = parseSequence(sequenceRaw)
   if (parsedSequence.startsUnavailable && port === 0) {
     throw new Error('dsh-llm-mock-server: connection_refused requires an explicit nonzero --port')

+ 7 - 3
packages/support/llm-mock-server/tests/cli.spec.ts

@@ -101,8 +101,11 @@ describe('mock LLM server CLI parser', () => {
 
   it.each([
     [[], /--sequence is required/],
-    [['--wat'], /requires a value/],
-    [['--wat', 'x'], /unknown option/],
+    // Tokenizer-level failures carry node:util parseArgs's own messages.
+    [['--wat'], /Unknown option '--wat'/],
+    [['--wat', 'x'], /Unknown option '--wat'/],
+    [['--port'], /Option '--port <value>' argument missing/],
+    [['--sequence', 'success', 'stray'], /Unexpected argument 'stray'/],
     [['--port', 'NaN', '--sequence', 'success'], /finite number/],
     [['--sequence', 'success,'], /non-empty/],
     [['--sequence', 'success,connection_refused'], /only as the first/],
@@ -110,7 +113,8 @@ describe('mock LLM server CLI parser', () => {
     [['--sequence', 'unknown'], /unknown behavior/],
     [['--sequence', 'connection_refused,success', '--port', '0'], /nonzero/],
     [['--sequence', 'success', '--listen-delay-ms', '5'], /requires connection_refused/],
-    [['--sequence', 'connection_refused,success', '--listen-delay-ms', '-1'], /integer between 0 and 2147483647/],
+    // `=` syntax: a space-separated leading-dash value is a tokenizer error, not a bounds probe.
+    [['--sequence', 'connection_refused,success', '--listen-delay-ms=-1'], /integer between 0 and 2147483647/],
     [['--sequence', 'connection_refused,success', '--listen-delay-ms', '1.5'], /integer between 0 and 2147483647/],
     [['--sequence', 'connection_refused,success', '--listen-delay-ms', '2147483648'], /integer between 0 and 2147483647/],
     [['--sequence', 'success', '--seed', '1'], /require random/],

+ 2 - 2
packages/support/loader-smoke/README.i18n.yaml

@@ -2,5 +2,5 @@
 # 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
-README.md: 8e53550608037a3c9a272db825933b7224ab24db
-README.zh.md: 5310429ab59cf3cd04ac024746f5ed557e003637
+README.md: 73610ce50ebac4c6fc7bb9135f7b41b347c60685
+README.zh.md: 17f8481220136e8edf9fccd23fabfca5ccf41dfc

+ 1 - 1
packages/support/loader-smoke/README.md

@@ -19,5 +19,5 @@ None; this package neither assembles nor sends a provider request.
 ## Known Limitations and Deferred Work
 
 - **Built mode requires a prior build** — the config must also resolve every named package upward through `examples/node_modules`.
-- **Captured stdout and stderr are unbounded** — a runaway child can consume memory until the deadline kills it.
+- **Captured stdout and stderr are bounded only by execa's default 100 MB `maxBuffer`** — a runaway child is terminated at that ceiling rather than at a smoke-chosen budget.
 - **Timeout kills only the direct child** — a process tree spawned by a faulty fixture can outlive the smoke and needs external cleanup.

+ 1 - 1
packages/support/loader-smoke/README.zh.md

@@ -19,5 +19,5 @@
 ## 已知限制与待完成工作
 
 - **构建 mode 需要事先构建**:配置还必须能够通过 `examples/node_modules` 向上解析每个命名包。
-- **捕获的 stdout 和 stderr 无界**:失控子进程可以消耗内存,直到 deadline 将其终止。
+- **捕获的 stdout 和 stderr 仅受 execa 默认 100 MB `maxBuffer` 约束**:失控子进程会在该上限处被终止,而不是在冒烟测试自选的预算处。
 - **超时只终止直接子进程**:故障 fixture 生成的进程树可以比冒烟测试存活更久,需要外部清理。

+ 1 - 0
packages/support/loader-smoke/package.json

@@ -27,6 +27,7 @@
   ],
   "license": "BSD-3-Clause",
   "dependencies": {
+    "execa": "^10.0.0",
     "tsx": "^4.22.4"
   },
   "peerDependencies": {

+ 20 - 46
packages/support/loader-smoke/src/index.ts

@@ -11,10 +11,10 @@
  * @module @deepseek-ai/dsh-loader-smoke
  */
 
-import { spawn } from 'node:child_process'
 import { mkdtemp, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
+import { execa } from 'execa'
 
 const DEFAULT_PROCESS_TIMEOUT_MS = 30_000
 
@@ -171,53 +171,27 @@ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise<Loade
       tsconfigPath: options.tsconfigPath,
       env: { DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents'), ...options.env },
     })
-    const result = await new Promise<LoaderSmokeResult>((resolve, reject) => {
-      const child = spawn(launch.command, launch.args, {
-        cwd,
-        env: { ...process.env, ...launch.env },
-        stdio: ['pipe', 'pipe', 'pipe'],
-      })
-      let stdout = ''
-      let stderr = ''
-      let deferredFailure: Error | undefined
-      child.stdout.setEncoding('utf8')
-      child.stdout.on('data', (chunk: string) => { stdout += chunk })
-      child.stderr.setEncoding('utf8')
-      child.stderr.on('data', (chunk: string) => { stderr += chunk })
-
-      const timer = setTimeout(() => {
-        deferredFailure = new Error(`${options.label} did not exit within ${processTimeoutMs / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`)
-        child.kill('SIGKILL')
-      }, processTimeoutMs)
-
-      child.once('exit', (code) => {
-        clearTimeout(timer)
-        if (deferredFailure !== undefined) {
-          reject(deferredFailure)
-        } else if (code === 0) {
-          resolve({ stdout, stderr })
-        } else {
-          reject(new Error(`${options.label} exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`))
-        }
-      })
-
-      // process.execPath and a just-created pipe make these OS-error paths
-      // impractical to induce without replacing the boundary under test.
-      /* v8 ignore start */
-      child.once('error', (error) => {
-        clearTimeout(timer)
-        reject(new Error(`${options.label} failed to start: ${error.message}`))
-      })
-      child.stdin.once('error', (error) => {
-        deferredFailure ??= new Error(`${options.label} stdin failed: ${error.message}`)
-        child.kill('SIGKILL')
-      })
-      /* v8 ignore stop */
-
-      child.stdin.end()
+    // `input: ''` writes nothing and closes stdin — the fixture-visible
+    // stdin-close contract. `reject: false` folds spawn errors, the SIGKILL
+    // deadline, and nonzero exits into independent result fields, so the
+    // diagnostics below embed both streams on every failure.
+    const result = await execa(launch.command, launch.args, {
+      cwd,
+      env: launch.env,
+      input: '',
+      timeout: processTimeoutMs,
+      killSignal: 'SIGKILL',
+      reject: false,
+      stripFinalNewline: false,
     })
+    if (result.timedOut) {
+      throw new Error(`${options.label} did not exit within ${processTimeoutMs / 1_000}s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
+    }
+    if (result.failed) {
+      throw new Error(`${options.label} exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
+    }
     await options.inspect?.(cwd)
-    return result
+    return { stdout: result.stdout, stderr: result.stderr }
   } finally {
     await rm(cwd, { recursive: true, force: true })
   }

+ 136 - 0
pnpm-lock.yaml

@@ -35,12 +35,18 @@ importers:
       '@vitest/coverage-v8':
         specifier: ^4.1.8
         version: 4.1.8(vitest@4.1.8)
+      '@yarnpkg/cli-dist':
+        specifier: 4.17.1
+        version: 4.17.1
       eslint:
         specifier: ^10.4.1
         version: 10.5.0(jiti@2.7.0)
       eslint-plugin-sonarjs:
         specifier: ^4.1.0
         version: 4.1.0(eslint@10.5.0(jiti@2.7.0))
+      execa:
+        specifier: ^10.0.0
+        version: 10.0.0
       fast-check:
         specifier: ^4.8.0
         version: 4.8.0
@@ -4047,6 +4053,9 @@ importers:
 
   packages/support/loader-smoke:
     dependencies:
+      execa:
+        specifier: ^10.0.0
+        version: 10.0.0
       tsx:
         specifier: ^4.22.4
         version: 4.22.4
@@ -6964,6 +6973,9 @@ packages:
     cpu: [x64]
     os: [win32]
 
+  '@sec-ant/readable-stream@0.4.1':
+    resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
+
   '@shikijs/core@2.5.0':
     resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==}
 
@@ -7016,6 +7028,10 @@ packages:
   '@shikijs/vscode-textmate@10.0.2':
     resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
 
+  '@sindresorhus/merge-streams@4.0.0':
+    resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
+    engines: {node: '>=18'}
+
   '@smithy/core@3.24.7':
     resolution: {integrity: sha512-KoUi4M1f3BG6kzN1FnCwL7oyFptTbyBJKjR6yhSib+JHRdUmM1o+VwsFtJ66NZCkCzVfJMWRHJNo0R0jznp0Pg==}
     engines: {node: '>=18.0.0'}
@@ -7497,6 +7513,11 @@ packages:
   '@xterm/headless@5.5.0':
     resolution: {integrity: sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g==}
 
+  '@yarnpkg/cli-dist@4.17.1':
+    resolution: {integrity: sha512-2tiSQuJNl/L3QwTdrq6lKWDpkcnp9MGvCT/rIldHcbu3SWfnLdmehvt3eulX1hT7FFt1Gjfq3CesF+kvhFip6g==}
+    engines: {node: '>=18.12.0'}
+    hasBin: true
+
   accepts@2.0.0:
     resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
     engines: {node: '>= 0.6'}
@@ -8161,6 +8182,10 @@ packages:
     resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
     engines: {node: '>=18.0.0'}
 
+  execa@10.0.0:
+    resolution: {integrity: sha512-Cxl6MKxB1dr1H0FHmiizJ+lavKF7pV+fcDZFyqMB8d5m7qUPm/OtZYcD5vPWePKxSnTQ57KuBd9mtdZ3oNCvyQ==}
+    engines: {node: '>=22'}
+
   expect-type@1.3.0:
     resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
     engines: {node: '>=12.0.0'}
@@ -8226,6 +8251,10 @@ packages:
     resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
     engines: {node: ^12.20 || >= 14.13}
 
+  figures@6.1.0:
+    resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
+    engines: {node: '>=18'}
+
   file-entry-cache@8.0.0:
     resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
     engines: {node: '>=16.0.0'}
@@ -8309,6 +8338,10 @@ packages:
     resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
     engines: {node: '>= 0.4'}
 
+  get-stream@9.0.1:
+    resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==}
+    engines: {node: '>=18'}
+
   get-tsconfig@4.14.0:
     resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
 
@@ -8408,6 +8441,10 @@ packages:
     resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
     engines: {node: '>= 14'}
 
+  human-signals@8.0.1:
+    resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
+    engines: {node: '>=18.18.0'}
+
   iconv-lite@0.6.3:
     resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
     engines: {node: '>=0.10.0'}
@@ -8496,6 +8533,14 @@ packages:
   is-promise@4.0.0:
     resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
 
+  is-stream@4.0.1:
+    resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==}
+    engines: {node: '>=18'}
+
+  is-unicode-supported@2.1.0:
+    resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
+    engines: {node: '>=18'}
+
   is-what@5.5.0:
     resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
     engines: {node: '>=18'}
@@ -9140,6 +9185,10 @@ packages:
   non-layered-tidy-tree-layout@2.0.2:
     resolution: {integrity: sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==}
 
+  npm-run-path@6.0.0:
+    resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
+    engines: {node: '>=18'}
+
   object-assign@4.1.1:
     resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
     engines: {node: '>=0.10.0'}
@@ -9215,6 +9264,10 @@ packages:
   parse-entities@4.0.2:
     resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
 
+  parse-ms@4.0.0:
+    resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
+    engines: {node: '>=18'}
+
   parse5@8.0.1:
     resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
 
@@ -9240,6 +9293,10 @@ packages:
     resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
     engines: {node: '>=8'}
 
+  path-key@4.0.0:
+    resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==}
+    engines: {node: '>=12'}
+
   path-scurry@1.11.1:
     resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
     engines: {node: '>=16 || 14 >=14.18'}
@@ -9300,6 +9357,10 @@ packages:
     resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
     engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
 
+  pretty-ms@9.3.0:
+    resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
+    engines: {node: '>=18'}
+
   process-nextick-args@2.0.1:
     resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
 
@@ -9605,6 +9666,10 @@ packages:
     resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
     engines: {node: '>=12'}
 
+  strip-final-newline@4.0.0:
+    resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
+    engines: {node: '>=18'}
+
   strip-json-comments@5.0.3:
     resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
     engines: {node: '>=14.16'}
@@ -9803,6 +9868,10 @@ packages:
     resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
     engines: {node: '>=20.18.1'}
 
+  unicorn-magic@0.3.0:
+    resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
+    engines: {node: '>=18'}
+
   unified@11.0.5:
     resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
 
@@ -10080,6 +10149,11 @@ packages:
     resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
     engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
 
+  which-command@0.1.0:
+    resolution: {integrity: sha512-XZyoF5/5hZtXitIwzrU4NKK+Wtbb9aB9CezUEw2Q0wlYK8NUYQxC1rRXgNueYLtBAJwXIb+/tFVk4dozciNJMA==}
+    engines: {node: '>=22'}
+    hasBin: true
+
   which@2.0.2:
     resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
     engines: {node: '>= 8'}
@@ -10143,6 +10217,10 @@ packages:
     resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
     engines: {node: '>=10'}
 
+  yoctocolors@2.1.2:
+    resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
+    engines: {node: '>=18'}
+
   zod-to-json-schema@3.25.2:
     resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
     peerDependencies:
@@ -11644,6 +11722,8 @@ snapshots:
   '@rollup/rollup-win32-x64-msvc@4.62.2':
     optional: true
 
+  '@sec-ant/readable-stream@0.4.1': {}
+
   '@shikijs/core@2.5.0':
     dependencies:
       '@shikijs/engine-javascript': 2.5.0
@@ -11722,6 +11802,8 @@ snapshots:
 
   '@shikijs/vscode-textmate@10.0.2': {}
 
+  '@sindresorhus/merge-streams@4.0.0': {}
+
   '@smithy/core@3.24.7':
     dependencies:
       '@aws-crypto/crc32': 5.2.0
@@ -12332,6 +12414,8 @@ snapshots:
 
   '@xterm/headless@5.5.0': {}
 
+  '@yarnpkg/cli-dist@4.17.1': {}
+
   accepts@2.0.0:
     dependencies:
       mime-types: 3.0.2
@@ -13068,6 +13152,22 @@ snapshots:
     dependencies:
       eventsource-parser: 3.1.0
 
+  execa@10.0.0:
+    dependencies:
+      '@sindresorhus/merge-streams': 4.0.0
+      figures: 6.1.0
+      get-stream: 9.0.1
+      human-signals: 8.0.1
+      is-plain-obj: 4.1.0
+      is-stream: 4.0.1
+      npm-run-path: 6.0.0
+      path-key: 4.0.0
+      pretty-ms: 9.3.0
+      signal-exit: 4.1.0
+      strip-final-newline: 4.0.0
+      which-command: 0.1.0
+      yoctocolors: 2.1.2
+
   expect-type@1.3.0: {}
 
   express-rate-limit@8.5.2(express@5.2.1):
@@ -13157,6 +13257,10 @@ snapshots:
       node-domexception: 1.0.0
       web-streams-polyfill: 3.3.3
 
+  figures@6.1.0:
+    dependencies:
+      is-unicode-supported: 2.1.0
+
   file-entry-cache@8.0.0:
     dependencies:
       flat-cache: 4.0.1
@@ -13253,6 +13357,11 @@ snapshots:
       dunder-proto: 1.0.1
       es-object-atoms: 1.1.2
 
+  get-stream@9.0.1:
+    dependencies:
+      '@sec-ant/readable-stream': 0.4.1
+      is-stream: 4.0.1
+
   get-tsconfig@4.14.0:
     dependencies:
       resolve-pkg-maps: 1.0.0
@@ -13390,6 +13499,8 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  human-signals@8.0.1: {}
+
   iconv-lite@0.6.3:
     dependencies:
       safer-buffer: 2.1.2
@@ -13449,6 +13560,10 @@ snapshots:
 
   is-promise@4.0.0: {}
 
+  is-stream@4.0.1: {}
+
+  is-unicode-supported@2.1.0: {}
+
   is-what@5.5.0: {}
 
   isarray@1.0.0: {}
@@ -14270,6 +14385,11 @@ snapshots:
   non-layered-tidy-tree-layout@2.0.2:
     optional: true
 
+  npm-run-path@6.0.0:
+    dependencies:
+      path-key: 4.0.0
+      unicorn-magic: 0.3.0
+
   object-assign@4.1.1: {}
 
   object-inspect@1.13.4: {}
@@ -14388,6 +14508,8 @@ snapshots:
       is-decimal: 2.0.1
       is-hexadecimal: 2.0.1
 
+  parse-ms@4.0.0: {}
+
   parse5@8.0.1:
     dependencies:
       entities: 8.0.0
@@ -14404,6 +14526,8 @@ snapshots:
 
   path-key@3.1.1: {}
 
+  path-key@4.0.0: {}
+
   path-scurry@1.11.1:
     dependencies:
       lru-cache: 10.4.3
@@ -14452,6 +14576,10 @@ snapshots:
       ansi-styles: 5.2.0
       react-is: 17.0.2
 
+  pretty-ms@9.3.0:
+    dependencies:
+      parse-ms: 4.0.0
+
   process-nextick-args@2.0.1: {}
 
   property-information@7.2.0: {}
@@ -14888,6 +15016,8 @@ snapshots:
     dependencies:
       ansi-regex: 6.2.2
 
+  strip-final-newline@4.0.0: {}
+
   strip-json-comments@5.0.3: {}
 
   strnum@2.4.0:
@@ -15048,6 +15178,8 @@ snapshots:
 
   undici@7.28.0: {}
 
+  unicorn-magic@0.3.0: {}
+
   unified@11.0.5:
     dependencies:
       '@types/unist': 3.0.3
@@ -15367,6 +15499,8 @@ snapshots:
     transitivePeerDependencies:
       - '@noble/hashes'
 
+  which-command@0.1.0: {}
+
   which@2.0.2:
     dependencies:
       isexe: 2.0.0
@@ -15408,6 +15542,8 @@ snapshots:
 
   yocto-queue@0.1.0: {}
 
+  yoctocolors@2.1.2: {}
+
   zod-to-json-schema@3.25.2(zod@4.4.3):
     dependencies:
       zod: 4.4.3

+ 33 - 0
scripts/client-tsconfig.spec.ts

@@ -0,0 +1,33 @@
+/** Regression coverage for source declarations owned by the client test aggregate. */
+
+import { existsSync, readdirSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import ts from 'typescript'
+import { describe, expect, it } from 'vitest'
+
+const root = fileURLToPath(new URL('..', import.meta.url))
+
+function clientCssDeclarations(): string[] {
+  const clientRoot = resolve(root, 'packages/client')
+  return readdirSync(clientRoot, { withFileTypes: true })
+    .filter(entry => entry.isDirectory())
+    .map(entry => resolve(clientRoot, entry.name, 'src/css-modules.d.ts'))
+    .filter(existsSync)
+    .sort()
+}
+
+describe('client TypeScript aggregate', () => {
+  it('loads package CSS declarations without relying on workspace-link realpaths', () => {
+    const configPath = resolve(root, 'tsconfig.client.json')
+    const read = ts.readConfigFile(configPath, file => ts.sys.readFile(file))
+    if (read.error !== undefined) {
+      throw new Error(ts.flattenDiagnosticMessageText(read.error.messageText, '\n'))
+    }
+    const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, root)
+    const loaded = parsed.fileNames
+      .filter(file => file.endsWith('/src/css-modules.d.ts'))
+      .sort()
+    expect(loaded).toEqual(clientCssDeclarations())
+  })
+})

+ 1 - 1
scripts/doc-budgets.manifest.json

@@ -1,5 +1,5 @@
 {
-  "AGENTS.md": 1680,
+  "AGENTS.md": 1695,
   "docs/AGENTS.md": 1150,
   "docs/architecture.md": 1800,
   "docs/cordis-primer.md": 600,

+ 223 - 0
scripts/wine-windows-gates.sh

@@ -0,0 +1,223 @@
+#!/usr/bin/env bash
+# Run the blocking Windows gates (workspace build, production site) with real
+# win-x64 Node.js under Wine — the same script the pull-request `windows` job
+# in ci.yml executes and the optional local gate `pnpm run check:windows-wine`
+# wraps. Owning rationale, fidelity limits, and measured timings:
+# .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md
+#
+# The working tree is never mutated: tracked plus untracked-unignored files
+# are snapshotted into a scratch directory, the Wine-specific pnpm overrides
+# (hoisted layout, win32-x64 platform packages) are appended to the SNAPSHOT's
+# pnpm-workspace.yaml, and the install and gates run there against the shared
+# pnpm store. The Wine prefix and the checksum-verified Windows Node zip
+# persist in .cache/wine-windows/ so reruns skip provisioning.
+#
+# Environment: DSH_WINE_NODE_MAJOR (default $PRIMARY_NODE_VERSION, then 24)
+# picks the Windows Node line; DSH_WINE_GATE_CACHE_DIR relocates the cache;
+# DSH_WINE_GATE_KEEP=1 preserves the scratch tree for inspection.
+
+set -euo pipefail
+
+repo_root="$(git rev-parse --show-toplevel)"
+node_major="${DSH_WINE_NODE_MAJOR:-${PRIMARY_NODE_VERSION:-24}}"
+cache_dir="${DSH_WINE_GATE_CACHE_DIR:-$repo_root/.cache/wine-windows}"
+
+export WINEDEBUG='-all'
+export WINEARCH=win64
+# Skip Wine Mono / Gecko installers: Node needs neither.
+export WINEDLLOVERRIDES='mscoree,mshtml='
+export WINEPREFIX="$cache_dir/prefix"
+
+# ---- preflight: fail loud before any expensive work --------------------
+wine_bin=''
+for candidate in "$(command -v wine || true)" "$(command -v wine64 || true)" /usr/lib/wine/wine64; do
+  if [ -n "$candidate" ] && [ -x "$candidate" ]; then wine_bin="$candidate"; break; fi
+done
+# GNU coreutils sha256sum on Linux; perl shasum ships with macOS. Both
+# accept the same "<hash>  <file>" --check input.
+checksum_tool=''
+if command -v sha256sum > /dev/null; then
+  checksum_tool='sha256sum'
+elif command -v shasum > /dev/null; then
+  checksum_tool='shasum'
+fi
+missing=()
+[ -n "$wine_bin" ] || missing+=('wine (apt: wine | brew: wine-stable)')
+command -v curl > /dev/null || missing+=('curl')
+command -v unzip > /dev/null || missing+=('unzip')
+[ -n "$checksum_tool" ] || missing+=('sha256sum or shasum (apt: coreutils | macOS ships shasum)')
+if ! command -v pnpm > /dev/null; then corepack enable > /dev/null 2>&1 || true; fi
+command -v pnpm > /dev/null || missing+=('pnpm (corepack enable)')
+if (( ${#missing[@]} > 0 )); then
+  printf 'wine-windows-gates: missing required tool: %s\n' "${missing[@]}" >&2
+  exit 1
+fi
+
+# Verify file $2 against SHA-256 hex $1 with whichever tool preflight found.
+verify_sha256() {
+  case "$checksum_tool" in
+    sha256sum) printf '%s  %s\n' "$1" "$2" | sha256sum --check - > /dev/null ;;
+    shasum) printf '%s  %s\n' "$1" "$2" | shasum -a 256 --check - > /dev/null ;;
+  esac
+}
+
+scratch="$(mktemp -d "${TMPDIR:-/tmp}/dsh-wine-gates.XXXXXX")"
+cleanup() {
+  wineserver -k > /dev/null 2>&1 || true
+  if [ "${DSH_WINE_GATE_KEEP:-0}" = '1' ]; then
+    echo "wine-windows-gates: scratch tree kept at $scratch"
+  else
+    rm -rf "$scratch"
+  fi
+}
+trap cleanup EXIT
+mkdir -p "$cache_dir" "$scratch/logs"
+
+# ---- provision Windows Node, boot Wine, snapshot + install concurrently ----
+provision_node() {
+  # Latest release of the primary line, checksum-verified against the same
+  # dist directory. Offline runs fall back to the newest cached zip, loudly.
+  local version zip
+  version="$(curl -fsSL --max-time 30 https://nodejs.org/dist/index.json 2> /dev/null \
+    | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{const v=JSON.parse(d).find(r=>r.version.startsWith('v$node_major.'));if(v)console.log(v.version)})" \
+    || true)"
+  if [ -n "$version" ]; then
+    zip="$cache_dir/node-$version-win-x64.zip"
+    if [ ! -f "$zip" ]; then
+      curl -fsSL -o "$zip.tmp" "https://nodejs.org/dist/$version/node-$version-win-x64.zip"
+      local expected
+      expected="$(curl -fsSL "https://nodejs.org/dist/$version/SHASUMS256.txt" \
+        | awk -v a="node-$version-win-x64.zip" '$2 == a { print $1; exit }')"
+      [ -n "$expected" ] || { echo "wine-windows-gates: no SHASUMS256 entry for node-$version-win-x64.zip" >&2; exit 1; }
+      verify_sha256 "$expected" "$zip.tmp"
+      mv "$zip.tmp" "$zip"
+    fi
+  else
+    zip="$(ls -t "$cache_dir"/node-v"$node_major".*-win-x64.zip 2> /dev/null | head -1 || true)"
+    [ -n "$zip" ] || { echo "wine-windows-gates: nodejs.org unreachable and no cached Windows Node v$node_major zip in $cache_dir" >&2; exit 1; }
+    echo "wine-windows-gates: nodejs.org unreachable; using cached $(basename "$zip")" >&2
+  fi
+  unzip -q -o "$zip" -d "$scratch/node-win"
+  echo "$scratch/node-win/$(basename "$zip" .zip)/node.exe" > "$scratch/node-win-path"
+}
+
+boot_wine() {
+  "$wine_bin" wineboot --init > /dev/null 2>&1 || true
+  wineserver -w || true
+}
+
+snapshot_and_install() {
+  # Tracked + untracked-unignored files, minus agent-session litter; the
+  # existence filter drops paths staged as deleted. Then the Wine-specific
+  # install-time overrides go on the SNAPSHOT only: hoisted because Windows
+  # Node under Wine does not realpath pnpm's isolated-layout symlinks, and
+  # win32-x64 so the Windows esbuild/rolldown/rollup binaries materialize.
+  # Neither is recorded in the lockfile, so --frozen-lockfile stays valid;
+  # --ignore-scripts skips host lifecycle scripts no gate loads.
+  git -C "$repo_root" ls-files -z --cached --others --exclude-standard -- . ':!:.claude' ':!:.codex' \
+    | while IFS= read -r -d '' file; do [ -e "$repo_root/$file" ] && printf '%s\0' "$file"; done \
+    | tar -C "$repo_root" --null --files-from=- -cf - \
+    | tar -C "$scratch/tree" -xf -
+  cat >> "$scratch/tree/pnpm-workspace.yaml" << 'EOF'
+
+nodeLinker: hoisted
+supportedArchitectures:
+  os: [current, win32]
+  cpu: [current, x64]
+EOF
+  (cd "$scratch/tree" && pnpm install --frozen-lockfile --ignore-scripts > "$scratch/logs/install.log" 2>&1) \
+    || { tail -40 "$scratch/logs/install.log" >&2; return 1; }
+}
+
+mkdir "$scratch/tree"
+start=$SECONDS
+provision_node & node_pid=$!
+boot_wine & wine_pid=$!
+snapshot_and_install & install_pid=$!
+# Wait for EVERY child before judging any: a bare `wait` under set -e would
+# exit on the first failure and let the EXIT trap delete $scratch while the
+# other children still run inside it. Named statuses also make the report
+# point at the root cause instead of a downstream symptom.
+node_status=0; wait "$node_pid" || node_status=$?
+wine_status=0; wait "$wine_pid" || wine_status=$?
+install_status=0; wait "$install_pid" || install_status=$?
+provision_failed=0
+report_provision() {
+  if (( $2 != 0 )); then
+    echo "wine-windows-gates: FAILED $1 (exit $2)" >&2
+    provision_failed=$2
+  fi
+}
+report_provision 'Windows Node provisioning' "$node_status"
+report_provision 'wineboot' "$wine_status"
+report_provision 'workspace snapshot + pnpm install' "$install_status"
+if (( provision_failed != 0 )); then exit "$provision_failed"; fi
+node_win="$(cat "$scratch/node-win-path")"
+echo "wine-windows-gates: provisioned in $((SECONDS - start))s (wine $("$wine_bin" --version 2> /dev/null), node $(basename "$(dirname "$node_win")"))"
+
+# ---- resolve entrypoints, lay the vue link, smoke ------------------------
+# Node under Wine cannot attach stdio to pipes the caller owns (Socket open
+# EBADF at bootstrap), so every invocation routes stdio through a file.
+wine_node() {
+  local log="$1"
+  shift
+  local status=0
+  "$wine_bin" "$node_win" "$@" < /dev/null > "$log" 2>&1 || status=$?
+  return "$status"
+}
+
+cd "$scratch/tree"
+tsc_js='node_modules/typescript/bin/tsc'
+tsdown_js='node_modules/tsdown/dist/run.mjs'
+vitepress_js='node_modules/vitepress/bin/vitepress.js'
+[ -f "$vitepress_js" ] || vitepress_js='website/node_modules/vitepress/bin/vitepress.js'
+for entry in "$tsc_js" "$tsdown_js" "$vitepress_js"; do
+  [ -f "$entry" ] || { echo "wine-windows-gates: expected entrypoint missing after hoisted install: $entry" >&2; exit 1; }
+done
+# VitePress links vue into the site's node_modules at build time; Wine cannot
+# CREATE Windows symlinks (ENOTSUP) but follows pre-existing Unix ones.
+if [ -d node_modules/vue ] && [ ! -e website/node_modules/vue ]; then
+  mkdir -p website/node_modules
+  ln -s ../../node_modules/vue website/node_modules/vue
+fi
+
+wine_node "$scratch/logs/smoke.log" -p "'smoke: ' + process.platform + ' ' + process.arch + ' ' + process.version"
+cat "$scratch/logs/smoke.log"
+grep -q '^smoke: win32 x64' "$scratch/logs/smoke.log" || { echo 'wine-windows-gates: Windows Node smoke did not report win32 x64' >&2; exit 1; }
+
+# ---- the two blocking surfaces, concurrently ------------------------------
+# The same shape run-gates gives ci-windows-blocking on native Windows:
+# `build` = tsc -b then tsdown, `production site` = the VitePress build. Both
+# statuses are captured so one failure cannot hide the other's result.
+build_gate() {
+  wine_node "$scratch/logs/tsc.log" "$tsc_js" -b --pretty false || return $?
+  wine_node "$scratch/logs/tsdown.log" "$tsdown_js"
+}
+site_gate() {
+  cd website
+  wine_node "$scratch/logs/site.log" "../$vitepress_js" build .
+}
+
+start=$SECONDS
+build_gate & build_pid=$!
+site_gate & site_pid=$!
+build_status=0
+wait "$build_pid" || build_status=$?
+site_status=0
+wait "$site_pid" || site_status=$?
+elapsed=$((SECONDS - start))
+
+report() {
+  local label="$1" status="$2"
+  shift 2
+  if (( status == 0 )); then
+    echo "wine-windows-gates: PASS $label (${elapsed}s window)"
+  else
+    echo "== FAILED $label (exit $status) ==" >&2
+    for log in "$@"; do tail -n 200 "$log" >&2 || true; done
+  fi
+}
+report 'build (tsc -b, tsdown)' "$build_status" "$scratch/logs/tsc.log" "$scratch/logs/tsdown.log"
+report 'production site (vitepress build)' "$site_status" "$scratch/logs/site.log"
+if (( build_status != 0 )); then exit "$build_status"; fi
+exit "$site_status"

+ 3 - 0
tsconfig.client.json

@@ -14,6 +14,9 @@
     "types": ["node"]
   },
   "include": [
+    // Source-subpath test imports can arrive through workspace links whose
+    // realpath semantics vary by host. Load package CSS declarations directly.
+    "packages/client/*/src/css-modules.d.ts",
     "packages/client/*/tests/**/*.ts",
     "packages/client/*/tests/**/*.tsx",
     "packages/client/tsdown.client.ts",