Răsfoiți Sursa

Merge remote-tracking branch 'origin/xtr/agent-loop-message-machine' into xtr/agent-loop-message-machine

_Kerman 1 lună în urmă
părinte
comite
6af8a2e1d8
32 a modificat fișierele cu 467 adăugiri și 427 ștergeri
  1. 6 0
      .agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml
  2. 37 0
      .agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md
  3. 37 0
      .agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md
  4. 0 6
      .agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml
  5. 0 41
      .agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md
  6. 0 41
      .agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md
  7. 2 2
      .agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml
  8. 1 1
      .agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md
  9. 1 1
      .agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md
  10. 12 19
      apps/cli/tests/built-bin.e2e.ts
  11. 3 12
      apps/web/tests/scaffold.ts
  12. 3 14
      apps/web/tests/smoke-real.e2e.ts
  13. 21 35
      examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts
  14. 26 29
      examples/tui-agent/tests/pty-harness.ts
  15. 1 0
      package.json
  16. 8 7
      packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts
  17. 15 20
      packages/examples/acp-demo/tests/built-bin.e2e.ts
  18. 25 27
      packages/examples/cli-demo/tests/built-bin.e2e.ts
  19. 8 7
      packages/lsp/lsp-local/tests/built-lib.e2e.ts
  20. 26 29
      packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts
  21. 2 2
      packages/support/acp-snapshot/README.i18n.yaml
  22. 1 1
      packages/support/acp-snapshot/README.md
  23. 1 1
      packages/support/acp-snapshot/README.zh.md
  24. 22 19
      packages/support/acp-snapshot/src/harness.ts
  25. 51 60
      packages/support/llm-mock-server/src/cli.ts
  26. 7 3
      packages/support/llm-mock-server/tests/cli.spec.ts
  27. 2 2
      packages/support/loader-smoke/README.i18n.yaml
  28. 1 1
      packages/support/loader-smoke/README.md
  29. 1 1
      packages/support/loader-smoke/README.zh.md
  30. 1 0
      packages/support/loader-smoke/package.json
  31. 20 46
      packages/support/loader-smoke/src/index.ts
  32. 126 0
      pnpm-lock.yaml

+ 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/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/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: 437130cc0e4456f40002f0467abaa4075d004181
-2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: 0e937793a71b03db02847e57ad2d4ef3e5a5a2af
+2026-07-26-dependency-swaps-rejected-by-nih-audit.md: 9cdb061bf21a7b9ce4747b9022c65d60e9644e0d
+2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: 9648af149c0c517eba372baa12877240f9289aac

+ 1 - 1
.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.
 

+ 1 - 1
.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 那一条腿。
 

+ 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'
@@ -69,16 +69,6 @@ const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/cordis.yml')
 // contextWindow keeps that pressure path provably inert for small fixtures.
 const REPLAY_PROVIDERS = [{ id: 'deepseek', name: 'DeepSeek', models: [{ id: '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. */
@@ -142,7 +132,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
package.json

@@ -114,6 +114,7 @@
     "@types/node": "^22.20.0",
     "@vitest/coverage-v8": "^4.1.8",
     "eslint": "^10.4.1",
+    "execa": "^10.0.0",
     "eslint-plugin-sonarjs": "^4.1.0",
     "fast-check": "^4.8.0",
     "js-yaml": "^4.2.0",

+ 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) ?? ''

+ 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 })
   }

+ 126 - 0
pnpm-lock.yaml

@@ -41,6 +41,9 @@ importers:
       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
@@ -4005,6 +4008,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
@@ -6922,6 +6928,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==}
 
@@ -6974,6 +6983,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'}
@@ -8119,6 +8132,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'}
@@ -8184,6 +8201,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'}
@@ -8267,6 +8288,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==}
 
@@ -8366,6 +8391,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'}
@@ -8454,6 +8483,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'}
@@ -9098,6 +9135,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'}
@@ -9173,6 +9214,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==}
 
@@ -9198,6 +9243,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'}
@@ -9258,6 +9307,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==}
 
@@ -9563,6 +9616,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'}
@@ -9761,6 +9818,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==}
 
@@ -10038,6 +10099,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'}
@@ -10101,6 +10167,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:
@@ -11602,6 +11672,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
@@ -11680,6 +11752,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
@@ -13026,6 +13100,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):
@@ -13115,6 +13205,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
@@ -13211,6 +13305,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
@@ -13348,6 +13447,8 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  human-signals@8.0.1: {}
+
   iconv-lite@0.6.3:
     dependencies:
       safer-buffer: 2.1.2
@@ -13407,6 +13508,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: {}
@@ -14228,6 +14333,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: {}
@@ -14346,6 +14456,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
@@ -14362,6 +14474,8 @@ snapshots:
 
   path-key@3.1.1: {}
 
+  path-key@4.0.0: {}
+
   path-scurry@1.11.1:
     dependencies:
       lru-cache: 10.4.3
@@ -14410,6 +14524,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: {}
@@ -14846,6 +14964,8 @@ snapshots:
     dependencies:
       ansi-regex: 6.2.2
 
+  strip-final-newline@4.0.0: {}
+
   strip-json-comments@5.0.3: {}
 
   strnum@2.4.0:
@@ -15006,6 +15126,8 @@ snapshots:
 
   undici@7.28.0: {}
 
+  unicorn-magic@0.3.0: {}
+
   unified@11.0.5:
     dependencies:
       '@types/unist': 3.0.3
@@ -15325,6 +15447,8 @@ snapshots:
     transitivePeerDependencies:
       - '@noble/hashes'
 
+  which-command@0.1.0: {}
+
   which@2.0.2:
     dependencies:
       isexe: 2.0.0
@@ -15366,6 +15490,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