1
0
Эх сурвалжийг харах

fix(subprocess): align native containment contract

pku-xht 2 долоо хоног өмнө
parent
commit
8e9bac4a27
59 өөрчлөгдсөн 1955 нэмэгдсэн , 373 устгасан
  1. 2 2
      .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml
  2. 9 5
      .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md
  3. 9 5
      .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md
  4. 2 2
      docs/subsystems/subprocess.i18n.yaml
  5. 2 2
      docs/subsystems/subprocess.md
  6. 2 2
      docs/subsystems/subprocess.zh.md
  7. 1 1
      examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/input.json
  8. 2 2
      examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
  9. 2 2
      packages/e2b/subprocess-e2b/README.i18n.yaml
  10. 2 2
      packages/e2b/subprocess-e2b/README.md
  11. 2 2
      packages/e2b/subprocess-e2b/README.zh.md
  12. 8 8
      packages/e2b/subprocess-e2b/src/process.ts
  13. 2 2
      packages/e2b/subprocess-e2b/tests/subprocess.spec.ts
  14. 1 1
      packages/extensions/cordis-client-runner/src/client/api-catalog.ts
  15. 2 2
      packages/extensions/tool-cordis/src/api-catalog.ts
  16. 2 2
      packages/lsp/lsp-stdio/src/connection.ts
  17. 30 0
      packages/lsp/lsp-stdio/tests/connection.spec.ts
  18. 1 1
      packages/shell/bash-sandbox/tests/sandbox.spec.ts
  19. 1 1
      packages/shell/pwsh-local/tests/executor.spec.ts
  20. 27 11
      packages/subagent/subagent-acp/src/run.ts
  21. 92 2
      packages/subagent/subagent-acp/tests/subagent-acp.spec.ts
  22. 15 37
      packages/subagent/subagent-claude-code/src/run.ts
  23. 45 9
      packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts
  24. 20 25
      packages/subagent/subagent-codex/src/run.ts
  25. 9 8
      packages/subagent/subagent-codex/tests/subagent-codex.spec.ts
  26. 2 2
      packages/subprocess/subprocess-local/README.i18n.yaml
  27. 6 6
      packages/subprocess/subprocess-local/README.md
  28. 6 6
      packages/subprocess/subprocess-local/README.zh.md
  29. 1 1
      packages/subprocess/subprocess-local/package.json
  30. 12 0
      packages/subprocess/subprocess-local/src/bin.ts
  31. 38 17
      packages/subprocess/subprocess-local/src/index.ts
  32. 88 28
      packages/subprocess/subprocess-local/src/linux-scope.ts
  33. 2 1
      packages/subprocess/subprocess-local/src/managed-owner.ts
  34. 31 11
      packages/subprocess/subprocess-local/src/runner-launch.ts
  35. 128 63
      packages/subprocess/subprocess-local/src/spawn-runner.ts
  36. 13 13
      packages/subprocess/subprocess-local/src/spawn.ts
  37. 44 1
      packages/subprocess/subprocess-local/src/terminal.ts
  38. 122 9
      packages/subprocess/subprocess-local/tests/linux-scope.spec.ts
  39. 105 19
      packages/subprocess/subprocess-local/tests/local.spec.ts
  40. 19 4
      packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts
  41. 148 1
      packages/subprocess/subprocess-local/tests/native-containment.spec.ts
  42. 729 9
      packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts
  43. 19 11
      packages/subprocess/subprocess-local/tests/spawn.spec.ts
  44. 108 0
      packages/subprocess/subprocess-local/tests/terminal.spec.ts
  45. 1 1
      packages/subprocess/subprocess-local/tsdown.config.ts
  46. 2 2
      packages/subprocess/subprocess/README.i18n.yaml
  47. 1 1
      packages/subprocess/subprocess/README.md
  48. 1 1
      packages/subprocess/subprocess/README.zh.md
  49. 2 2
      packages/subprocess/subprocess/src/types.ts
  50. 16 1
      packages/subprocess/subprocess/tests/service.spec.ts
  51. 2 2
      packages/subprocess/win32-process/README.i18n.yaml
  52. 2 2
      packages/subprocess/win32-process/README.md
  53. 2 2
      packages/subprocess/win32-process/README.zh.md
  54. 2 2
      packages/subprocess/win32-process/src/index.ts
  55. 5 5
      packages/subprocess/win32-process/src/process.ts
  56. 4 4
      packages/subprocess/win32-process/tests/ordinary-process.spec.ts
  57. 1 1
      packages/typert/generator/src/cordis-catalog.ts
  58. 3 1
      packages/typert/generator/tests/cordis-catalog.spec.ts
  59. 0 8
      vitest.config.ts

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.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/bug-fix/2026-08-20-subprocess-native-containment.md
-2026-08-20-subprocess-native-containment.md: f1094f42cdec16e8b5d1717975fe108800c65cb8
-2026-08-20-subprocess-native-containment.zh.md: 63b9a4a9691b9d7a964bf4992cea9ec228e499ca
+2026-08-20-subprocess-native-containment.md: 7772284635435ecf11dbc0416021c8b701535275
+2026-08-20-subprocess-native-containment.zh.md: 538677ca4bd53192188d1a412f6cf649c32deac8

+ 9 - 5
.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md

@@ -1,4 +1,4 @@
-# Agent Note: Ordinary subprocesses use native managed ranges where supported
+# Agent Note: Local subprocesses use native managed ranges where supported
 
 Status: implemented
 
@@ -10,17 +10,21 @@ The local subprocess provider treated a POSIX process group or a Windows direct-
 
 ## Decision
 
-`LocalSubprocessRuntime` selects ordinary native containment once, before its first user command. Linux uses a transient user-systemd scope only when the user manager is readable and `systemd-run` supports `--expand-environment=no`. Windows uses a local runner backed by `@deepseek-ai/dsh-win32-process`; it creates the target suspended, assigns it to a kill-on-close Job, and resumes it only after assignment. Each launch binds a package-private owner with only `signal()` and `waitForExit()` responsibilities.
+`LocalSubprocessRuntime` selects containment before every eligible ordinary or terminal user command; capability results are not cached, while the weaker-path warning is emitted at most once per provider. Linux uses a transient user-systemd scope only when the user manager is readable and `systemd-run` supports `--expand-environment=no`. Windows ordinary launch uses a local runner backed by `@deepseek-ai/dsh-win32-process`; it creates the target suspended, assigns it to a kill-on-close Job, and resumes it only after assignment. Each native launch binds a package-private owner with only `signal()` and `waitForExit()` responsibilities.
 
 The common spawn lifecycle still owns stdio dispositions, bounded collection, direct outcome, abort handling, termination scheduling, and host-exit registration. Linux scope and POSIX process-group owners deliver TERM and then KILL after the configured grace; Windows Job and `taskkill` owners force-terminate on the first request. `.done` comes from the target process. A private `0600` single-spawn request/event transport lets the Linux or Windows runner report Node-shaped target spawn failures and the target exit independently of the scope or Job lifetime. `waitForExit()` succeeds only after the same owner used by `terminate()` confirms that the OS range is empty; once confirmed, the owner permanently ignores later signals.
 
-Linux user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. The packaged carrier re-enters its executable through the private dispatch owned by the [single-file runtime](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md), and the Linux capability probe invokes that same runner entry before selecting native mode. Scope TERM leaves the runner alive long enough to report a TERM-trapping target. If scope KILL prevents a final target event, the Linux launch reports `SIGKILL` only after that KILL was attempted and the owner proves the scope empty; an unrelated runner or manager failure still rejects. On Windows the parent creates private named-pipe endpoints for non-inherited streams, while the runner opens only the target-side handles. That runner creates the target suspended, assigns it to its unnamed kill-on-close Job, resumes it, publishes the target identity, and closes its pipe handles in the same synchronous startup step before processing control messages. It retains the original target process handle and Job until it has reported direct exit and `QueryInformationJobObject` reports zero active members. The parent never opens the target process or Job; IPC termination and disconnect remain the only control path into the runner.
+Linux ordinary user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. The packaged carrier re-enters its executable through the private dispatch owned by the [single-file runtime](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md), and the Linux capability probe invokes that same runner entry before selecting native mode. Scope TERM leaves the runner alive long enough to report a TERM-trapping target. If scope KILL prevents a final target event, the Linux launch reports `SIGKILL` only after that KILL was attempted and the owner proves the scope empty; an unrelated runner or manager failure still rejects.
+
+Linux terminal launch passes `systemd-run --user --scope --quiet --collect --expand-environment=no -- <original argv>` directly to `node-pty`; `systemd-run --scope` replaces itself with the target, so node-pty continues to observe the target PID, session leader, process group, controlling terminal, foreground input wait, and prompt readiness. The terminal handle binds the same scope owner for normal termination and host-exit KILL, so a descendant that reparents or creates a new session remains in the managed range without a second PTY runner or a continuous process-table monitor.
+
+On Windows the parent creates private named-pipe endpoints for non-inherited streams, while the runner opens only the target-side handles. That runner creates the target suspended, assigns it to its unnamed kill-on-close Job, resumes it, publishes the target identity, and closes its pipe handles in the same synchronous startup step before processing control messages. It retains the original target process handle and Job until it has reported direct exit and `QueryInformationJobObject` reports zero active members. The parent never opens the target process or Job; IPC termination and disconnect remain the only control path into the runner.
 
 When native capability is unavailable before target execution, the provider warns once and uses the existing PGID or `taskkill /T` fallback. macOS always takes that path because it has no supported public persistent process owner. After native launch is selected, any runner, manager, or result-transport failure is reported; the user command is never replayed through fallback.
 
 ## Verification
 
-Linux native evidence on Ubuntu 24.04 x86_64 with systemd 255.4 runs one real `setsid` and reparenting scenario plus Node-shaped spawn failures without replay. Windows native evidence covers one default-inheritance descendant scenario plus raw stdin, direct stdout/stderr EOF, direct result versus Job quiescence, and target spawn failures. Shared tests pin literal argv, one-time fallback warnings, unreadable-owner rejection, no post-stop signals, abort and host-exit routing, and source, built, and packaged-executable runner entries.
+Linux native evidence on Ubuntu 24.04 x86_64 with systemd 255.4 runs separate ordinary and node-pty `setsid`/reparenting scenarios plus Node-shaped spawn failures without replay. The PTY scenario pins the node-pty PID, process group, session leader, controlling terminal, `/dev/tty` input, foreground `inputWaiting`, and termination of the escaped descendant. Windows native evidence covers one default-inheritance descendant scenario plus raw stdin, direct stdout/stderr EOF, direct result versus Job quiescence, and target spawn failures. Shared tests pin literal argv, one-time fallback warnings, unreadable-owner rejection, no post-stop signals, abort and host-exit routing, and source, built, and packaged-executable runner entries.
 
 ## Alternatives considered
 
@@ -36,4 +40,4 @@ Linux native evidence on Ubuntu 24.04 x86_64 with systemd 255.4 runs one real `s
 
 ## Consequences
 
-Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. The first ordinary spawn probes capability once per provider instance with a 5-second bound per probe command. The local native path then requires a bounded per-launch handshake before it can publish the target pid; the fixed upper bound is 10 seconds when a runner never reports, and each native range retains one runner process until settlement. Windows also creates private per-spawn named-pipe endpoints, but no named Job or parent target-process handle. After publication, event-file reads use asynchronous 100 ms polling and systemd state reads use asynchronous 200 ms polling rather than blocking the host event loop. Windows managed ranges terminate immediately; `graceMs` still bounds collected-pipe draining. The private runner adds one built entry and short-lived private files but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee.
+Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. Every eligible ordinary or terminal spawn probes capability before target execution with a 5-second bound per probe command. Native ordinary launch then requires a bounded per-launch handshake before it can publish the target pid; the fixed upper bound is 10 seconds when a runner never reports, and each native ordinary range retains one runner process until settlement. Linux PTY launch adds no runner. Windows also creates private per-spawn named-pipe endpoints, but no named Job or parent target-process handle. After publication, event-file reads use asynchronous 100 ms polling and systemd state reads use asynchronous 200 ms polling rather than blocking the host event loop. Windows managed ranges terminate immediately; `graceMs` still bounds collected-pipe draining. The private runner adds one built entry and short-lived private files but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee.

+ 9 - 5
.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md

@@ -1,4 +1,4 @@
-# Agent Note: Ordinary subprocesses use native managed ranges where supported
+# Agent Note: Local subprocesses use native managed ranges where supported
 
 Status: implemented
 
@@ -10,17 +10,21 @@ Status: implemented
 
 ## Decision
 
-`LocalSubprocessRuntime` 在首个用户命令之前只选择一次 ordinary native containment。Linux 只在 user manager 可读且 `systemd-run` 支持 `--expand-environment=no` 时使用 transient user-systemd scope。Windows 使用由 `@deepseek-ai/dsh-win32-process` 支撑的本地 runner;它以 suspended 状态创建目标,把目标分配给 kill-on-close Job,并只在分配后恢复。每次 launch 只绑定一个提供 `signal()` 与 `waitForExit()` 职责的 package-private owner。
+`LocalSubprocessRuntime` 会在每次符合条件的 ordinary 或 terminal 用户命令前选择 containment;capability 结果不会缓存,较弱路径的告警则由每个 provider 至多发出一次。Linux 只在 user manager 可读且 `systemd-run` 支持 `--expand-environment=no` 时使用 transient user-systemd scope。Windows ordinary launch 使用由 `@deepseek-ai/dsh-win32-process` 支撑的本地 runner;它以 suspended 状态创建目标,把目标分配给 kill-on-close Job,并只在分配后恢复。每次 native launch 只绑定一个提供 `signal()` 与 `waitForExit()` 职责的 package-private owner。
 
 common spawn lifecycle 继续拥有 stdio disposition、有界收集、direct outcome、abort 处理、termination scheduling 与 host-exit 注册。Linux scope 与 POSIX 进程组 owner 先投递 TERM,并在配置的 grace 后投递 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`.done` 来自 target process。private `0600` single-spawn request/event transport 让 Linux 或 Windows runner 分别报告 Node-shaped target spawn failure 与 target exit,不依赖 scope 或 Job 生命周期。`waitForExit()` 只在 `terminate()` 使用的同一 owner 确认 OS range 为空后成功;首次确认后,该 owner 永久忽略后续 signal。
 
-Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标。若 scope KILL 阻止最终 target event,Linux launch 只会在该 KILL 已尝试且 owner 证明 scope 为空后报告 `SIGKILL`;无关的 runner 或 manager failure 仍会拒绝。Windows parent 为非继承流创建 private named-pipe endpoint,runner 只打开 target 侧 handle。该 runner 以 suspended 状态创建目标,把目标分配给自身 unnamed kill-on-close Job,恢复目标,在处理 control message 前的同一个同步启动步骤中发布 target identity 并关闭自身 pipe handle。它会保留原始 target process handle 与 Job,直到报告 direct exit 且 `QueryInformationJobObject` 报告 active member 归零。parent 不打开 target process 或 Job;IPC termination 与 disconnect 是进入 runner 的唯一控制路径。
+Linux ordinary user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标。若 scope KILL 阻止最终 target event,Linux launch 只会在该 KILL 已尝试且 owner 证明 scope 为空后报告 `SIGKILL`;无关的 runner 或 manager failure 仍会拒绝。
+
+Linux terminal launch 会把 `systemd-run --user --scope --quiet --collect --expand-environment=no -- <原始 argv>` 直接交给 `node-pty`;`systemd-run --scope` 会以 target 替换自身,因此 node-pty 继续观察 target PID、session leader、process group、控制终端、前台 input wait 与 prompt readiness。terminal handle 会为正常终止与 host-exit KILL 绑定同一个 scope owner,因此已 reparent 或新建 session 的 descendant 仍留在 managed range 内,无需第二个 PTY runner 或持续进程表 monitor。
+
+Windows parent 为非继承流创建 private named-pipe endpoint,runner 只打开 target 侧 handle。该 runner 以 suspended 状态创建目标,把目标分配给自身 unnamed kill-on-close Job,恢复目标,在处理 control message 前的同一个同步启动步骤中发布 target identity 并关闭自身 pipe handle。它会保留原始 target process handle 与 Job,直到报告 direct exit 且 `QueryInformationJobObject` 报告 active member 归零。parent 不打开 target process 或 Job;IPC termination 与 disconnect 是进入 runner 的唯一控制路径。
 
 native capability 在目标执行前不可用时,provider 只告警一次并使用既有 PGID 或 `taskkill /T` fallback。macOS 因没有受支持的公开 persistent process owner,始终进入该路径。native launch 一旦被选择,runner、manager 或 result transport 的任何失败都会直接报告;用户命令绝不会经 fallback 重放。
 
 ## Verification
 
-Linux native 证据在 Ubuntu 24.04 x86_64、systemd 255.4 环境运行一个真实 `setsid` 与 reparenting 场景,并覆盖不重放的 Node-shaped spawn failure。Windows native 证据运行一个默认继承 descendant 场景,并覆盖 raw stdin、direct stdout/stderr EOF、direct result 与 Job quiescence 的区别,以及 target spawn failure。shared tests 固定 literal argv、一次性 fallback warning、owner 不可读时拒绝、停稳后不再发 signal、abort 与 host-exit 路由,以及 source、built 和 packaged-executable runner entry。
+Linux native 证据在 Ubuntu 24.04 x86_64、systemd 255.4 环境分别运行 ordinary 与 node-pty `setsid`/reparenting 场景,并覆盖不重放的 Node-shaped spawn failure。PTY 场景固定 node-pty PID、process group、session leader、控制终端、`/dev/tty` 输入、前台 `inputWaiting`,以及 escaped descendant 的终止。Windows native 证据运行一个默认继承 descendant 场景,并覆盖 raw stdin、direct stdout/stderr EOF、direct result 与 Job quiescence 的区别,以及 target spawn failure。shared tests 固定 literal argv、一次性 fallback warning、owner 不可读时拒绝、停稳后不再发 signal、abort 与 host-exit 路由,以及 source、built 和 packaged-executable runner entry。
 
 ## Alternatives considered
 
@@ -36,4 +40,4 @@ Linux native 证据在 Ubuntu 24.04 x86_64、systemd 255.4 环境运行一个真
 
 ## Consequences
 
-受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。首条 ordinary spawn 会为每个 provider instance 探测一次能力,每条 probe command 的上限为 5 秒。本地 native 路径随后必须在发布 target pid 前完成每次 launch 的有界握手;runner 始终不报告时,固定上限为 10 秒,每个 native range 还会保留一个 runner process 直到 settlement。Windows 还会创建 private per-spawn named-pipe endpoint,但不会创建 named Job 或 parent target-process handle。handle 发布后,event file 使用异步 100 ms 轮询,systemd state 使用异步 200 ms 轮询,不再阻塞宿主事件循环。Windows managed range 会立即终止;`graceMs` 仍用于限制 collected-pipe 排空。private runner 增加一个 built entry 和短期 private files,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。
+受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。每次符合条件的 ordinary 或 terminal spawn 都会在 target 执行前探测能力,每条 probe command 的上限为 5 秒。native ordinary launch 随后必须在发布 target pid 前完成每次 launch 的有界握手;runner 始终不报告时,固定上限为 10 秒,每个 native ordinary range 还会保留一个 runner process 直到 settlement。Linux PTY launch 不增加 runner。Windows 还会创建 private per-spawn named-pipe endpoint,但不会创建 named Job 或 parent target-process handle。handle 发布后,event file 使用异步 100 ms 轮询,systemd state 使用异步 200 ms 轮询,不再阻塞宿主事件循环。Windows managed range 会立即终止;`graceMs` 仍用于限制 collected-pipe 排空。private runner 增加一个 built entry 和短期 private files,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。

+ 2 - 2
docs/subsystems/subprocess.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 docs/subsystems/subprocess.md
-subprocess.md: 701d70629587c202c50047fb88158f39179920d1
-subprocess.zh.md: 643998ae0210e0fa2cdf8d5140809df07beaa27b
+subprocess.md: 2ee79c8d9605ebf8c375e7bfad84063f5e1b17b9
+subprocess.zh.md: 01a837fa7613a714c3ce1d5f4ec4c29d3a632a26

+ 2 - 2
docs/subsystems/subprocess.md

@@ -144,8 +144,8 @@ A spawn returns a live handle synchronously; the provider may publish its proces
  * observe.
  */
 interface SubprocessHandle {
-  /** Provider-published process identifier; -1 while unavailable or after startup fails. */
-  readonly pid: number
+  /** Provider-published target process identifier, or undefined until it is available. */
+  readonly pid: number | undefined
   /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */
   readonly stdin: Writable | undefined
   /** The child's raw stdout, present iff spawned with `stdout: 'pipe'`. */

+ 2 - 2
docs/subsystems/subprocess.zh.md

@@ -144,8 +144,8 @@ spawn 会同步返回活动句柄;provider 可以稍后发布其进程标识
  * observe.
  */
 interface SubprocessHandle {
-  /** Provider-published process identifier; -1 while unavailable or after startup fails. */
-  readonly pid: number
+  /** Provider-published target process identifier, or undefined until it is available. */
+  readonly pid: number | undefined
   /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */
   readonly stdin: Writable | undefined
   /** The child's raw stdout, present iff spawned with `stdout: 'pipe'`. */

+ 1 - 1
examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/input.json

@@ -2,6 +2,6 @@
   "steps": [
     { "op": "initialize" },
     { "op": "newSession" },
-    { "op": "prompt", "text": "Inspect the exact tools service API and tools/pre-execute event with cordis_inspect_query, then reply with exactly CORDIS_INSPECT_JSDOC_OK." }
+    { "op": "prompt", "text": "Inspect the exact tools service API, tools/pre-execute event, and subprocess service API with cordis_inspect_query, then reply with exactly CORDIS_INSPECT_JSDOC_OK." }
   ]
 }

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 2 - 2
examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl


+ 2 - 2
packages/e2b/subprocess-e2b/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 packages/e2b/subprocess-e2b/README.md
-README.md: 6e0ef633577377af6045c8b81d6f1e141bc46b54
-README.zh.md: 460c6c60fefce67e6813805c68405a24c64be9cb
+README.md: baa86b345f65c55515cb2e880d1fe707432184f9
+README.zh.md: 3476bca21da304517763b279cbc0f3a49a1d3724

+ 2 - 2
packages/e2b/subprocess-e2b/README.md

@@ -12,7 +12,7 @@ E2B implementation of the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subpr
 
 ## Behavior
 
-- **Asynchronous remote start** — the synchronous seam returns a handle immediately while `Sandbox.commands.run(..., { background: true })` starts remotely. `pid` is `-1` until the wrapper publishes and the adapter validates its process-group id; stdin and ordinary observation wait for that publication. An owned startup signal aborts environment and private-state preparation before allocation; once allocation begins, cancellation waits for a provisional SDK handle it can clean.
+- **Asynchronous remote start** — the synchronous seam returns a handle immediately while `Sandbox.commands.run(..., { background: true })` starts remotely. `pid` is `undefined` until the wrapper publishes and the adapter validates its process-group id; stdin and ordinary observation wait for that publication. An owned startup signal aborts environment and private-state preparation before allocation; once allocation begins, cancellation waits for a provisional SDK handle it can clean.
 - **Execution-world coordinates** — `cwd` and private `runtimeRoot` come from the shared owner; executable lookup verifies absolute paths or resolves a bare name against the sandbox PATH plus explicit overrides, and rejects relative paths containing separators like every subprocess provider.
 - **Linux process groups** — a quoted wrapper starts each argv under `exec setsid --wait` and records its actual process-group id plus private status files beneath `ctx.e2b.runtimeRoot/processes`. The handle waits for that file instead of treating the SDK command PID as its published identity. Termination signals the negative recorded id with `SIGTERM`, waits the caller's `graceMs`, then escalates to `SIGKILL` and the SDK kill fallback; TERM delivery or probe failures also force that escalation. Process-table probes treat groups containing only zombie or dead entries as quiescent. Force cleanup succeeds only after a bounded probe finds the group empty; otherwise `waitForExit()` exposes a retryable failure, while proven quiescence makes later termination a no-op. Publication and monitoring failures apply the same cleanup transaction before rejecting. Service disposal rejects new starts, terminates and joins every retained process group, then awaits SDK settlement and private cleanup before the sandbox owner disposes.
 - **Environment boundary** — one trusted control-shell probe resolves the sandbox user's login home from its passwd entry and transports the sandbox environment as base64 ASCII for one strict UTF-8 decode; the wrapper then removes ambient `DSH_*` and credential-shaped (`*KEY*`, `*SECRET*`, `*TOKEN*`) names and restores every valid `spec.env` entry as an explicit caller opt-in. Empty names, `=`, and NUL framing violations reject before launch. Subsequent E2B command and PTY login shells receive a fresh randomized root-level `HOME` plus empty overrides for every scrubbed ambient name before user profiles can run; the requested argv receives the serialized environment afterward without changing the sandbox user's umask. Host ambient variables never enter the sandbox implicitly. Private environment files are removed after consumption, and failed command or terminal setup removes its private state before rejecting.
@@ -33,7 +33,7 @@ No direct invalidation; the named consumers own any request-prefix changes.
 ## Known Limitations and Deferred Work
 
 - **The SDK still retains complete command output in host memory** — E2B `CommandHandle.stdout` and `.stderr` accumulate the base64 transport even when this adapter exposes bounded raw-byte tails, so the subprocess seam's normal host-memory bound is not achieved and transport retention is larger than the source stream.
-- **Synchronous-PID consumers are unsupported** — `pid` remains `-1` during remote startup; consumers that require a positive PID immediately, including the ACP child backend, cannot use this provider unchanged.
+- **Synchronous-PID consumers are unsupported** — `pid` remains `undefined` during remote startup; consumers that require a PID immediately cannot use this provider unchanged.
 - **Private state lives for the sandbox lifetime** — process directories and valid spill files remain under `.dsh-e2b` until the owner deletes the sandbox; this POC supplies no in-sandbox sweep.
 - **Control state shares the sandbox user's UID** — E2B runs every command as the same default user, so `0700`/`0600` modes cannot isolate `.dsh-e2b` control files from concurrently running sandbox processes. A background process could rewrite `pid`/`exit-code` or read a not-yet-consumed `environment` file. The adapter validates published values and refuses group ids whose negative form is unsafe to signal (`<= 1`), but real isolation needs an E2B per-command user or an out-of-band control channel.
 - **Numeric process identities are not reuse-fenced** — E2B exposes numeric PID/PGID PTY input, signalling, and cleanup operations but no atomic identity-bound alternative. The adapter minimizes host round trips and live coverage exercises the reproducible stale-interrupt overlap; replacement is deferred until E2B adds an identity primitive or a failure demonstrates a narrower protocol.

+ 2 - 2
packages/e2b/subprocess-e2b/README.zh.md

@@ -12,7 +12,7 @@
 
 ## 行为
 
-- **异步远程启动**:同步 seam 会立即返回一个句柄,同时由 `Sandbox.commands.run(..., { background: true })` 在远程启动进程。包装层发布进程组 ID 并由适配器完成验证之前,`pid` 为 `-1`;stdin 和常规观察会等待该发布。自有启动信号会在分配前中止环境和私有状态准备;分配开始后,取消会等待可清理的临时 SDK 句柄。
+- **异步远程启动**:同步 seam 会立即返回一个句柄,同时由 `Sandbox.commands.run(..., { background: true })` 在远程启动进程。包装层发布进程组 ID 并由适配器完成验证之前,`pid` 为 `undefined`;stdin 和常规观察会等待该发布。自有启动信号会在分配前中止环境和私有状态准备;分配开始后,取消会等待可清理的临时 SDK 句柄。
 - **执行世界坐标**:`cwd` 和私有 `runtimeRoot` 来自共享所有者;可执行文件查找会验证绝对路径,或根据沙箱 PATH 加显式覆盖来解析裸名称,并与所有 subprocess 提供方一致地拒绝含分隔符的相对路径。
 - **Linux 进程组**:带引号保护的包装层会在 `exec setsid --wait` 下启动每组 argv,并在 `ctx.e2b.runtimeRoot/processes` 下记录实际进程组 ID 和私有状态文件。句柄会等待该文件,而不会把 SDK 命令 PID 当作已发布的身份。终止操作以记录的负数 ID 发送 `SIGTERM`,等待调用方的 `graceMs`,再升级到 `SIGKILL` 和 SDK kill 回退;TERM 信号发送或探测失败也会强制触发该升级。进程表探测会把仅含僵尸或已死亡条目的进程组视为完全停稳。强制清理只有在有界探测发现进程组为空后才算成功;否则 `waitForExit()` 会公开可重试的失败,而已证明的完全停稳会让后续终止操作不再执行任何动作。发布失败与监控失败都会在拒绝前执行同一清理事务。服务 dispose(资源释放)会拒绝新的启动请求、终止并等待每个保留进程组退出,再等待 SDK 结算和私有清理完成,之后沙箱所有者才会释放沙箱。
 - **环境边界**:一次受信任的控制 shell 探测会从 passwd 条目解析沙箱用户的登录主目录,以 base64 ASCII 传输沙箱环境,再进行一次严格 UTF-8 解码;随后包装层移除环境中的 `DSH_*` 和形似凭据的名称(`*KEY*`、`*SECRET*`、`*TOKEN*`),并把每个有效的 `spec.env` 条目恢复为调用方显式选择。空名称、`=` 和违反 NUL 分帧规则的条目会在启动前被拒绝。在用户 profile 脚本运行前,此后的 E2B 命令 shell 与 PTY 登录 shell 会获得位于根目录下、全新随机生成的 `HOME`,并为每个被清理的环境变量名设置空值覆盖;之后,请求的 argv 会在不改变沙箱用户 umask 的前提下接收序列化环境。宿主环境变量绝不会隐式进入沙箱。私有环境文件在使用后会被删除;命令或终端设置失败时,会先删除其私有状态再拒绝。
@@ -33,7 +33,7 @@ E2B 默认基础镜像提供该适配器调用的运行时和 Bash/GNU 工具:
 ## 已知限制与延后工作
 
 - **SDK 仍会在宿主内存中保留完整命令输出**:即使本适配器公开的是有界原始字节尾部,E2B `CommandHandle.stdout` 和 `.stderr` 仍会累积 base64 传输内容,因此无法达到进程管理 seam 通常提供的宿主内存边界,而且传输保留量大于源数据流。
-- **不支持需要同步 PID 的消费方**:远程启动期间,`pid` 保持为 `-1`;包括 ACP(Agent Client Protocol)子进程后端在内,要求立即获得正 PID 的消费方无法原样使用本提供方。
+- **不支持需要同步 PID 的消费方**:远程启动期间,`pid` 保持为 `undefined`;要求立即获得 PID 的消费方无法原样使用本提供方。
 - **私有状态随沙箱生命周期存在**:进程目录和有效的 spill 文件会留在 `.dsh-e2b` 下,直到所有者删除沙箱;本 POC 不提供沙箱内清理。
 - **控制状态与沙箱用户同 UID**:E2B 以同一默认用户运行每条命令,因此 `0700`/`0600` 权限无法把 `.dsh-e2b` 控制文件与并发运行的沙箱进程隔离开。后台进程可以改写 `pid`/`exit-code`,或读取尚未被消费的 `environment` 文件。适配器会验证已发布的值,并拒绝取负后不安全的进程组 ID(`<= 1`),但真正的隔离需要 E2B 提供按命令用户或带外控制通道。
 - **数值进程身份没有复用围栏**:E2B 公开基于数值 PID/PGID 的 PTY 输入、信号发送和清理操作,却没有与身份原子绑定的替代方案。适配器会尽量减少宿主往返,真实环境测试会覆盖可复现的陈旧中断重叠;在 E2B 新增身份原语,或实际故障证明需要更窄的协议之前,替代方案会继续延后。

+ 8 - 8
packages/e2b/subprocess-e2b/src/process.ts

@@ -174,7 +174,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
   private readonly stderrReader: E2BOutputReader | undefined
   private readonly paths: RemotePaths
   private controlEnvs: Record<string, string> = {}
-  private remotePid = -1
+  private remotePid: number | undefined
   private outputTransportError: Error | undefined
   private outputDrainExpired = false
   private stateDirectoryCreated = false
@@ -225,8 +225,8 @@ export class E2BSubprocessHandle implements SubprocessHandle {
     if (spec.signal?.aborted === true) this.terminate()
   }
 
-  /** Remote process id after start; `-1` while E2B startup is pending or after it fails. */
-  get pid(): number {
+  /** Remote process id after publication; undefined while startup is pending or unavailable. */
+  get pid(): number | undefined {
     return this.remotePid
   }
 
@@ -260,7 +260,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
         this.markQuiescent()
         return true
       }
-      if (this.remotePid <= 0) {
+      if (this.remotePid === undefined) {
         const attempt = this.terminationAttempt
         if (attempt !== undefined && await waitWithSignal(attempt.catch(() => undefined), signal) === WAIT_ABORTED) {
           return false
@@ -293,7 +293,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
       }
       throw error
     }
-    const processGroupId = this.remotePid > 0 ? this.remotePid : handle.pid
+    const processGroupId = this.remotePid ?? handle.pid
     while (await this.groupAlive(sandbox, processGroupId, signal)) {
       this.throwTerminationFailure()
       if (!await waitTick(this.pollMs, signal)) return false
@@ -559,7 +559,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
   }
 
   private async rollbackPublishedFailure(error: unknown): Promise<unknown> {
-    if (this.remotePid <= 0 || this.quiescenceProven) return error
+    if (this.remotePid === undefined || this.quiescenceProven) return error
     this.terminate()
     try {
       await this.waitForExit()
@@ -599,13 +599,13 @@ export class E2BSubprocessHandle implements SubprocessHandle {
       this.markQuiescent()
       return
     }
-    if (!isValidProcessId(handle.pid) && this.remotePid <= 0) {
+    if (!isValidProcessId(handle.pid) && this.remotePid === undefined) {
       await handle.kill()
       this.markQuiescent()
       return
     }
     const sandbox = await this.runtime.getSandbox()
-    const processGroupId = this.remotePid > 0 ? this.remotePid : handle.pid
+    const processGroupId = this.remotePid ?? handle.pid
     await this.terminateGroup(sandbox, handle, processGroupId)
   }
 

+ 2 - 2
packages/e2b/subprocess-e2b/tests/subprocess.spec.ts

@@ -402,7 +402,7 @@ describe('E2BSubprocessHandle', () => {
         KEEP: undefined,
       },
     }), '/workspace/.dsh-e2b/processes/one')
-    expect(handle.pid).toBe(-1)
+    expect(handle.pid).toBeUndefined()
     handle.stdin!.write('hello')
     handle.stdin!.end()
     fake.releaseStart()
@@ -1166,7 +1166,7 @@ describe('E2BSubprocessHandle', () => {
     fake.backgroundError = new Error('start failed')
     const handle = testHandle(runtime(fake), spec(), '/runtime/fail')
     await expect(handle.done).rejects.toThrow('start failed')
-    expect(handle.pid).toBe(-1)
+    expect(handle.pid).toBeUndefined()
     expect(fake.removed).toContain('/runtime/fail/environment')
     expect(fake.removed).toContain('/runtime/fail')
     await expect(handle.waitForExit()).resolves.toBe(true)

+ 1 - 1
packages/extensions/cordis-client-runner/src/client/api-catalog.ts

@@ -888,7 +888,7 @@ function referencedTypeClosure(seeds: readonly string[]): TypeApiEntry[] {
     const next: string[] = []
     for (const entry of TYPE_API) {
       if (included.has(entry.name)) continue
-      const pattern = new RegExp(`\b${entry.name}\b`)
+      const pattern = new RegExp(`\\b${entry.name}\\b`)
       if (!frontier.some(text => pattern.test(text))) continue
       included.add(entry.name)
       next.push(entry.declaration)

+ 2 - 2
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -4539,7 +4539,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'SubprocessHandle',
-    declaration: 'export interface SubprocessHandle {\n    readonly pid: number;\n    readonly stdin: Writable | undefined;\n    readonly stdout: Readable | undefined;\n    readonly stderr: Readable | undefined;\n    readonly collected: SubprocessCollectedOutputs;\n    readonly done: Promise<SubprocessOutcome>;\n    terminate(): void;\n    waitForExit(signal?: AbortSignal): Promise<boolean>;\n}',
+    declaration: 'export interface SubprocessHandle {\n    readonly pid: number | undefined;\n    readonly stdin: Writable | undefined;\n    readonly stdout: Readable | undefined;\n    readonly stderr: Readable | undefined;\n    readonly collected: SubprocessCollectedOutputs;\n    readonly done: Promise<SubprocessOutcome>;\n    terminate(): void;\n    waitForExit(signal?: AbortSignal): Promise<boolean>;\n}',
   },
   {
     name: 'SubprocessOutcome',
@@ -5076,7 +5076,7 @@ function referencedTypeClosure(seeds: readonly string[]): TypeApiEntry[] {
     const next: string[] = []
     for (const entry of TYPE_API) {
       if (included.has(entry.name)) continue
-      const pattern = new RegExp(`\b${entry.name}\b`)
+      const pattern = new RegExp(`\\b${entry.name}\\b`)
       if (!frontier.some(text => pattern.test(text))) continue
       included.add(entry.name)
       next.push(entry.declaration)

+ 2 - 2
packages/lsp/lsp-stdio/src/connection.ts

@@ -131,8 +131,8 @@ export class LspConnection {
     this.handle.stdout.on('data', (chunk: Buffer) => { this.onStdout(chunk) })
   }
 
-  /** The child's pid, or `-1` when the spawn produced no pid (so signalling is a no-op). */
-  get pid(): number {
+  /** The child's published pid, or undefined while the provider has none available. */
+  get pid(): number | undefined {
     return this.handle.pid
   }
 

+ 30 - 0
packages/lsp/lsp-stdio/tests/connection.spec.ts

@@ -1,8 +1,10 @@
 import { afterEach, describe, expect, it } from 'vitest'
+import { PassThrough } from 'node:stream'
 import { fileURLToPath } from 'node:url'
 import { LspConnection } from '@deepseek-ai/dsh-lsp-stdio'
 import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-stdio/src/connection.ts'
 import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
+import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
 import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts'
 
 const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
@@ -51,6 +53,34 @@ describe('LspConnection', () => {
     expect(conn.pid).toBeGreaterThan(0)
   })
 
+  it('projects an unavailable subprocess pid as undefined', async () => {
+    const direct = Promise.withResolvers<SubprocessOutcome>()
+    const handle: SubprocessHandle = {
+      pid: undefined,
+      stdin: new PassThrough(),
+      stdout: new PassThrough(),
+      stderr: undefined,
+      collected: {},
+      done: direct.promise,
+      terminate: () => {},
+      waitForExit: async () => true,
+    }
+    const conn = new LspConnection({
+      command: 'language-server',
+      args: [],
+      cwd: process.cwd(),
+      env: {},
+      maxMessageBytes: 1_000,
+      maxStderrBytes: 1_000,
+      killGraceMs: 100,
+      configuration: null,
+    }, () => handle, () => Promise.resolve(null))
+
+    expect(conn.pid).toBeUndefined()
+    direct.resolve({ exitCode: 0, signal: null })
+    await conn.closed
+  })
+
   it('forwards explicit DSH_* env entries to the child', async () => {
     // A configured DSH_* fact must reach the child: the seam scrubs only the
     // ambient namespace, and the explicit entry merges after that scrub. The

+ 1 - 1
packages/shell/bash-sandbox/tests/sandbox.spec.ts

@@ -564,7 +564,7 @@ describe('background sandbox facts', () => {
       readFrom: () => ({ text: '', nextOffset: 0, lossy: false }),
     }
     vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue({
-      pid: -1,
+      pid: undefined,
       stdin: undefined,
       stdout: undefined,
       stderr: undefined,

+ 1 - 1
packages/shell/pwsh-local/tests/executor.spec.ts

@@ -163,7 +163,7 @@ describe('spawn construction (pure, every platform)', () => {
     override spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
       this.specs.push(spec)
       return {
-        pid: -1,
+        pid: undefined,
         stdin: undefined,
         stdout: undefined,
         stderr: undefined,

+ 27 - 11
packages/subagent/subagent-acp/src/run.ts

@@ -112,18 +112,25 @@ async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise<boo
  * @param eofGraceMs - tier-1 window after stdin EOF.
  */
 export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: number): Promise<void> {
-  // A spawn failure has no process to tear down; observe the rejection so
-  // disposal in a finally block cannot surface it as unhandled.
-  if (child.pid <= 0) {
-    await child.done.catch(() => {})
-    return
-  }
+  const failures: Error[] = []
   child.stdin?.end()
-  if (await treeExitsWithin(child, eofGraceMs)) return
+  let exited = false
+  try {
+    exited = await treeExitsWithin(child, eofGraceMs)
+  } catch (error: unknown) {
+    failures.push(toError(error))
+  }
+  if (exited) return
   // terminate() owns the bounded SIGTERM→SIGKILL timer. Its unbounded wait is
   // the process owner's exit proof, not a second derived grace that can overflow.
   child.terminate()
-  await child.waitForExit()
+  try {
+    await child.waitForExit()
+  } catch (error: unknown) {
+    failures.push(toError(error))
+  }
+  if (failures.length === 1) throw failures[0] as Error
+  if (failures.length > 1) throw new AggregateError(failures, 'ACP subprocess teardown failed')
 }
 
 /**
@@ -311,9 +318,18 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
     ])
   } catch (error: unknown) {
     request.signal.removeEventListener('abort', onAbort)
-    await disposeProcess()
-    if (flags.cancelled) throw new Error('subagent request was aborted before the ACP child started')
-    throw toError(error)
+    const startupFailure = flags.cancelled
+      ? new Error('subagent request was aborted before the ACP child started')
+      : toError(error)
+    try {
+      await disposeProcess()
+    } catch (cleanupError: unknown) {
+      throw new AggregateError(
+        [startupFailure, toError(cleanupError)],
+        'ACP startup failed and subprocess rollback did not reach quiescence',
+      )
+    }
+    throw startupFailure
   }
   // The startup transaction validates the returned id before it can fulfill.
   // This assertion carries that cross-closure invariant into TypeScript.

+ 92 - 2
packages/subagent/subagent-acp/tests/subagent-acp.spec.ts

@@ -1,4 +1,5 @@
-import { describe, expect, it } from 'vitest'
+import { PassThrough } from 'node:stream'
+import { describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
 import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
@@ -8,7 +9,7 @@ import { fileURLToPath } from 'node:url'
 import SubagentRuntime from '@deepseek-ai/dsh-subagent'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
-import type { SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
+import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
 import * as acp from '../src/index.ts'
 import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, disposeAcpChild, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts'
 import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
@@ -196,6 +197,95 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)',
     await expect(disposeAcpChild(child, 1_000)).resolves.toBeUndefined()
     await expect(child.done).rejects.toThrow()
   })
+
+  it('still terminates and performs the final wait when the EOF wait rejects', async () => {
+    const initialFailure = new Error('initial range observation failed')
+    const waitForExit = vi.fn()
+      .mockRejectedValueOnce(initialFailure)
+      .mockResolvedValueOnce(true)
+    const terminate = vi.fn()
+    const child: SubprocessHandle = {
+      pid: undefined,
+      stdin: new PassThrough(),
+      stdout: undefined,
+      stderr: undefined,
+      collected: {},
+      done: new Promise(() => {}),
+      terminate,
+      waitForExit,
+    }
+
+    await expect(disposeAcpChild(child, 1_000)).rejects.toBe(initialFailure)
+    expect(terminate).toHaveBeenCalledOnce()
+    expect(waitForExit).toHaveBeenCalledTimes(2)
+  })
+
+  it('preserves both wait failures in observation order', async () => {
+    const initialFailure = new Error('initial range observation failed')
+    const finalFailure = new Error('final range observation failed')
+    const waitForExit = vi.fn()
+      .mockRejectedValueOnce(initialFailure)
+      .mockRejectedValueOnce(finalFailure)
+    const child: SubprocessHandle = {
+      pid: undefined,
+      stdin: new PassThrough(),
+      stdout: undefined,
+      stderr: undefined,
+      collected: {},
+      done: new Promise(() => {}),
+      terminate: vi.fn(),
+      waitForExit,
+    }
+
+    let failure: unknown
+    try {
+      await disposeAcpChild(child, 1_000)
+    } catch (error: unknown) {
+      failure = error
+    }
+    expect(failure).toBeInstanceOf(AggregateError)
+    expect((failure as AggregateError).errors).toEqual([initialFailure, finalFailure])
+  })
+
+  it('keeps a startup failure before its rollback failure', async () => {
+    const startupFailure = new Error('target startup failed')
+    const cleanupFailure = new Error('range cleanup failed')
+    const direct = Promise.withResolvers<SubprocessOutcome>()
+    const stdin = new PassThrough()
+    const stdout = new PassThrough()
+    const child: SubprocessHandle = {
+      pid: undefined,
+      stdin,
+      stdout,
+      stderr: undefined,
+      collected: {},
+      done: direct.promise,
+      terminate: vi.fn(),
+      waitForExit: vi.fn()
+        .mockResolvedValueOnce(false)
+        .mockRejectedValueOnce(cleanupFailure),
+    }
+    const starting = startAcpRun(request(), {
+      command: 'fake-acp',
+      args: [],
+      cwd: process.cwd(),
+      permission: 'reject',
+      env: {},
+      disposeEofGraceMs: 1_000,
+      disposeGraceMs: 1_000,
+      spawn: () => child,
+    })
+    direct.reject(startupFailure)
+
+    let failure: unknown
+    try {
+      await starting
+    } catch (error: unknown) {
+      failure = error
+    }
+    expect(failure).toBeInstanceOf(AggregateError)
+    expect((failure as AggregateError).errors).toEqual([startupFailure, cleanupFailure])
+  })
 })
 
 describe('cwd resolution', () => {

+ 15 - 37
packages/subagent/subagent-claude-code/src/run.ts

@@ -282,7 +282,7 @@ export async function disposeClaudeCodeChild(
   } catch (error: unknown) {
     failures.push(thrown(error))
   }
-  const outcome = await child.done
+  const outcome = await child.done.catch(() => undefined)
 
   const firstFailure = failures[0]
   if (firstFailure !== undefined) {
@@ -400,6 +400,7 @@ export async function startClaudeCodeRun(
   }
 
   let child: SubprocessHandle | undefined
+  let childFailure: Error | undefined
   let query: Query | undefined
   let managedProcess: ManagedClaudeCodeProcess | undefined
   let diagnostic: string | undefined
@@ -418,6 +419,7 @@ export async function startClaudeCodeRun(
   ): void => {
     child = captured
     managedProcess = process
+    void captured.done.catch((error: unknown) => { childFailure = thrown(error) })
   }
   try {
     query = officialQuery({
@@ -429,11 +431,16 @@ export async function startClaudeCodeRun(
         capturePermissionDiagnostic,
       ),
     })
-    if (child === undefined || child.pid <= 0) {
+    if (child === undefined) {
       throw new Error(
         'subagent-claude-code: official SDK did not publish a controllable Claude Code process',
       )
     }
+    // A provider may publish no PID and reject `done` through several already-
+    // queued promise reactions. PID absence is not failure; give that complete
+    // synchronous rejection chain one event-loop turn before publication.
+    await new Promise<void>((resolve) => { setImmediate(resolve) })
+    if (childFailure !== undefined) throw childFailure
     if (controller.signal.aborted) {
       throw new Error('subagent-claude-code: request was aborted before SDK startup')
     }
@@ -448,32 +455,17 @@ export async function startClaudeCodeRun(
       category: 'unknown',
       outcome: startupOutcome,
     } as const
-    const startupFailure = (cause: unknown = error): ClaudeCodeFailure => new ClaudeCodeFailure(
+    const startupFailure = (cause: unknown = childFailure ?? error): ClaudeCodeFailure => new ClaudeCodeFailure(
       startupFacts,
       thrown(cause),
     )
     requestCancel()
-    if (child !== undefined && child.pid <= 0) {
-      let closeError: Error | undefined
+    if (child !== undefined) {
       try {
-        query?.close()
+        await disposeClaudeCodeChild(query, child)
       } catch (disposeError: unknown) {
-        closeError = thrown(disposeError)
-      }
-
-      let spawnError = thrown(error)
-      try {
-        await child.done
-      } catch (childError: unknown) {
-        spawnError = thrown(childError)
-      }
-
-      if (closeError !== undefined) {
-        const failure = startupFailure(spawnError)
-        const cleanupFailure = new ClaudeCodeFailure({
-          stage: 'teardown',
-          category: 'unknown',
-        }, closeError)
+        const failure = startupFailure()
+        const cleanupFailure = thrown(disposeError)
         const aggregate = new AggregateError(
           [failure, cleanupFailure],
           `${failure.message}; ${cleanupFailure.message}`,
@@ -484,23 +476,9 @@ export async function startClaudeCodeRun(
       if (cancelledBeforeCleanup || isAborted(request.signal)) {
         throw new Error('subagent-claude-code: request was aborted before SDK startup')
       }
-      const failure = startupFailure(spawnError)
+      const failure = startupFailure()
       reportFailure(failure)
       throw failure
-    }
-    if (child !== undefined) {
-      try {
-        await disposeClaudeCodeChild(query, child)
-      } catch (disposeError: unknown) {
-        const failure = startupFailure()
-        const cleanupFailure = thrown(disposeError)
-        const aggregate = new AggregateError(
-          [failure, cleanupFailure],
-          `${failure.message}; ${cleanupFailure.message}`,
-        )
-        reportFailure(aggregate)
-        throw aggregate
-      }
     } else if (query !== undefined) {
       try {
         query.close()

+ 45 - 9
packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts

@@ -100,7 +100,7 @@ function errorCause(value: unknown): Error | undefined {
 }
 
 interface FakeChildOptions {
-  readonly pid?: number
+  readonly pid?: number | undefined
   readonly exitOnTerminate?: boolean
   readonly waitForExitError?: Error
   readonly doneError?: Error
@@ -169,7 +169,7 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild {
     })
   })
   const handle: SubprocessHandle = {
-    pid: options.pid ?? 1234,
+    pid: Object.hasOwn(options, 'pid') ? options.pid : 1234,
     stdin,
     stdout,
     stderr: undefined,
@@ -802,7 +802,7 @@ describe('official spawn projection', () => {
   })
 
   it('emits spawn errors', async () => {
-    const child = fakeChild({ pid: -1 })
+    const child = fakeChild({ pid: undefined })
     const process = new ManagedClaudeCodeProcess(child.handle)
     const errorListener = vi.fn()
     const removed = vi.fn()
@@ -1299,6 +1299,16 @@ describe('run publication, cancellation, and settlement', () => {
     )).rejects.toThrow('aborted before SDK startup')
     expect(unused.options).toEqual([])
 
+    const thrownAbort = new AbortController()
+    queryMock.mockImplementationOnce(() => {
+      thrownAbort.abort(new Error('startup cancelled before resource publication'))
+      throw new Error('query failed before resource publication')
+    })
+    await expect(startClaudeCodeRun(
+      request(undefined, thrownAbort.signal),
+      unused.spec,
+    )).rejects.toThrow('aborted before SDK startup')
+
     const noChildClose = vi.fn()
     queryMock.mockImplementationOnce(
       () => queryFrom([], undefined, noChildClose),
@@ -1439,7 +1449,7 @@ describe('run publication, cancellation, and settlement', () => {
       { code: 'EACCES', path: '/sdk/claude' },
     )
     const failedSpawn = fakeChild({
-      pid: -1,
+      pid: undefined,
       doneError: spawnError,
     })
     const failed = fakeRun([], undefined, failedSpawn)
@@ -1449,12 +1459,12 @@ describe('run publication, cancellation, and settlement', () => {
     await expect(failedStartup).rejects.not.toThrow('spawn /sdk/claude EACCES')
     await expect(failedStartup).rejects.toMatchObject({ cause: spawnError })
     expect(failed.close).toHaveBeenCalledOnce()
-    expect(failedSpawn.terminate).not.toHaveBeenCalled()
-    expect(failedSpawn.waitForExit).not.toHaveBeenCalled()
+    expect(failedSpawn.terminate).toHaveBeenCalledOnce()
+    expect(failedSpawn.waitForExit).toHaveBeenCalledOnce()
 
     const failedSpawnAbort = new AbortController()
     const cancelledFailedSpawn = fakeChild({
-      pid: -1,
+      pid: undefined,
       doneError: spawnError,
     })
     const cancelledFailedClose = vi.fn()
@@ -1474,7 +1484,7 @@ describe('run publication, cancellation, and settlement', () => {
       throw cancelledFailedSpawnCloseError
     })
     const cancelledFailedSpawnWithCloseFailure = fakeChild({
-      pid: -1,
+      pid: undefined,
       doneError: spawnError,
     })
     const failedSpawnAbortWithCloseFailure = new AbortController()
@@ -1507,7 +1517,7 @@ describe('run publication, cancellation, and settlement', () => {
     const failedSpawnCloseError = new Error('query close failed')
     const failedSpawnClose = vi.fn(() => { throw failedSpawnCloseError })
     const failedSpawnWithCloseFailure = fakeChild({
-      pid: -1,
+      pid: undefined,
       doneError: spawnError,
     })
     queryMock.mockImplementationOnce(({ options }) => {
@@ -1555,6 +1565,32 @@ describe('run publication, cancellation, and settlement', () => {
     await expect(liveCleanupFailure)
       .rejects.not.toThrow('live child cleanup failed')
   })
+
+  it('waits one event-loop turn for a queued provider startup rejection', async () => {
+    const spawnError = Object.assign(
+      new Error('spawn /sdk/claude ENOENT'),
+      { code: 'ENOENT', path: '/sdk/claude' },
+    )
+    const child = fakeChild({ pid: undefined })
+    const close = vi.fn()
+    queryMock.mockImplementationOnce(({ options }) => {
+      options.spawnClaudeCodeProcess!(sdkSpawnOptions())
+      queueMicrotask(() => { child.fail(spawnError) })
+      return queryFrom([], undefined, close)
+    })
+
+    const startup = startClaudeCodeRun(request(), {
+      cwd: '/workspace',
+      permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE,
+      env: {},
+      disposeGraceMs: 5,
+      spawn: () => child.handle,
+    })
+    await expect(startup).rejects.toMatchObject({ cause: spawnError })
+    expect(close).toHaveBeenCalledOnce()
+    expect(child.terminate).toHaveBeenCalledOnce()
+    expect(child.waitForExit).toHaveBeenCalledOnce()
+  })
 })
 
 describe('query and process disposal', () => {

+ 20 - 25
packages/subagent/subagent-codex/src/run.ts

@@ -188,32 +188,27 @@ export async function disposeCodexChild(
 ): Promise<void> {
   wire.close()
 
-  if (child.pid > 0) {
-    let outcome: SubprocessOutcome | undefined
-    void child.done.then(
-      (value) => { outcome = value },
-      /* v8 ignore next -- a positive pid excludes spawn-level done rejection. */
-      () => {},
-    )
-    try {
-      child.stdin?.end()
-    } catch {
-      // A concurrently closed stdin does not change tree ownership below.
-    }
-    child.terminate()
-    try {
-      await child.waitForExit()
-    } catch (error: unknown) {
-      throw new CodexRunFailure({
-        stage: 'teardown',
-        category: 'unknown',
-        outcome,
-      }, thrown(error))
-    }
-    await child.done
-  } else {
-    await child.done.catch(() => {})
+  let outcome: SubprocessOutcome | undefined
+  void child.done.then(
+    (value) => { outcome = value },
+    () => {},
+  )
+  try {
+    child.stdin?.end()
+  } catch {
+    // A concurrently closed stdin does not change tree ownership below.
+  }
+  child.terminate()
+  try {
+    await child.waitForExit()
+  } catch (error: unknown) {
+    throw new CodexRunFailure({
+      stage: 'teardown',
+      category: 'unknown',
+      outcome,
+    }, thrown(error))
   }
+  await child.done.catch(() => {})
 }
 
 /**

+ 9 - 8
packages/subagent/subagent-codex/tests/subagent-codex.spec.ts

@@ -140,7 +140,7 @@ class ProtocolPeer {
 }
 
 interface FakeChildOptions {
-  readonly pid?: number
+  readonly pid?: number | undefined
   readonly exitOnTerminate?: boolean
   readonly doneError?: Error
   readonly waitForExitError?: Error
@@ -212,7 +212,7 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild {
     })
   })
   const handle: SubprocessHandle = {
-    pid: options.pid ?? 1234,
+    pid: Object.hasOwn(options, 'pid') ? options.pid : 1234,
     stdin: toChild,
     stdout: fromChild,
     stderr,
@@ -1912,7 +1912,7 @@ describe('run lifecycle and quiescence', () => {
     await expect(spawnFailure).rejects.not.toThrow('SECRET_TOKEN')
 
     const asyncSpawnFailureChild = fakeChild({
-      pid: -1,
+      pid: undefined,
       doneError: new Error('SECRET_TOKEN async spawn failure'),
     })
     const asyncSpawnFailure = startCodexRun(
@@ -1922,7 +1922,8 @@ describe('run lifecycle and quiescence', () => {
     await expect(asyncSpawnFailure)
       .rejects.toThrow(expectedFailureDiagnostic('initialize', 'unknown'))
     await expect(asyncSpawnFailure).rejects.not.toThrow('SECRET_TOKEN')
-    expect(asyncSpawnFailureChild.terminate).not.toHaveBeenCalled()
+    expect(asyncSpawnFailureChild.terminate).toHaveBeenCalledOnce()
+    expect(asyncSpawnFailureChild.waitForExit).toHaveBeenCalledOnce()
 
     const child = fakeChild()
     const starting = startCodexRun(request(), runSpec(child))
@@ -2295,16 +2296,16 @@ describe('disposeCodexChild', () => {
       .resolves.toBeUndefined()
   })
 
-  it('handles a spawn-level failure with no process tree', async () => {
+  it('still runs idempotent cleanup when the target pid was never published', async () => {
     const child = fakeChild({
-      pid: -1,
+      pid: undefined,
       doneError: new Error('spawn failed'),
     })
     const wire = defaultWire(child)
     await expect(disposeCodexChild(wire, child.handle))
       .resolves.toBeUndefined()
-    expect(child.terminate).not.toHaveBeenCalled()
-    expect(child.waitForExit).not.toHaveBeenCalled()
+    expect(child.terminate).toHaveBeenCalledOnce()
+    expect(child.waitForExit).toHaveBeenCalledOnce()
   })
 
   it('reports tree-wait failure with safe teardown facts', async () => {

+ 2 - 2
packages/subprocess/subprocess-local/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 packages/subprocess/subprocess-local/README.md
-README.md: 30dc28a0be9db966ef8ffc1286ffa07858c8f81d
-README.zh.md: 1a5a6c17efad9ea2a1e0fb2980b23e61679a68f5
+README.md: d8cdcbaec8a33b2f9a8af3a1a75dac2e647cce18
+README.zh.md: 7ec64e45f459a6c6a00aa4153d5ffaecb6c00dd0

+ 6 - 6
packages/subprocess/subprocess-local/README.md

@@ -2,19 +2,19 @@
 
 English | [中文](README.zh.md)
 
-Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam. `LocalSubprocessRuntime` resolves local executables, gives ordinary Linux and Windows commands an OS-owned managed range when the host supports it, and implements terminal processes through `node-pty` plus platform process inspection. It has no config: every disposition, limit, terminal dimension, grace, and directory arrives from the calling capability seams ([`dsh-bash-local`](../../shell/bash-local/README.md), [`dsh-lsp-stdio`](../../lsp/lsp-stdio/README.md), and [`dsh-terminal-bash`](../../terminal/terminal-bash/README.md)).
+Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam. `LocalSubprocessRuntime` resolves local executables, gives ordinary Linux and Windows commands plus Linux terminal sessions an OS-owned managed range when the host supports it, and implements terminal processes through `node-pty` plus platform process inspection. It has no config: every disposition, limit, terminal dimension, grace, and directory arrives from the calling capability seams ([`dsh-bash-local`](../../shell/bash-local/README.md), [`dsh-lsp-stdio`](../../lsp/lsp-stdio/README.md), and [`dsh-terminal-bash`](../../terminal/terminal-bash/README.md)).
 
 ## Behavior
 
-- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state. On Windows the parent creates private named-pipe endpoints for non-inherited streams; the runner opens only the target-side handles, creates the target suspended, assigns it to its kill-on-close Job, resumes it, publishes startup, and then closes those pipe handles. The runner alone retains the original target process handle and Job, reports the direct result, and exits successfully only after `ActiveProcesses` reaches zero; the parent never opens either native object. Linux scopes and POSIX process-group fallbacks receive TERM and then KILL after `graceMs`; Windows Job and `taskkill` owners force-terminate on the first request. `waitForExit()` succeeds only after the selected owner proves the range empty and rejects when that proof is unavailable. After the direct result arrives, `.done` waits up to `graceMs` for every non-inherited output stream to close; at that bound, only collected streams are force-closed while raw pipes remain caller-owned.
+- **One managed range for signal and wait** — Linux ordinary commands and terminal sessions use transient user-systemd scopes when the manager supports literal argv and readable scope state. On Windows the parent creates private named-pipe endpoints for non-inherited streams; the runner opens only the target-side handles, creates the target suspended, assigns it to its kill-on-close Job, resumes it, publishes startup, and then closes those pipe handles. The runner alone retains the original target process handle and Job, reports the direct result, and exits successfully only after `ActiveProcesses` reaches zero; the parent never opens either native object. Linux scopes and POSIX process-group fallbacks receive TERM and then KILL after `graceMs`; Windows Job and `taskkill` owners force-terminate on the first request. `waitForExit()` succeeds only after the selected owner proves the range empty and rejects when that proof is unavailable. After the direct result arrives, `.done` waits up to `graceMs` for every non-inherited output stream to close; at that bound, only collected streams are force-closed while raw pipes remain caller-owned.
 - **Explicit weaker fallback** — macOS, old or unavailable user-systemd, and unavailable Windows native support keep the existing detached PGID or `taskkill /T` path. The provider warns once before the first affected command. It never retries through fallback after a native runner may have started the user command.
 - **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory.
 - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
 - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement.
 - **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd.
-- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes start identities with zero-time process-handle waits for liveness, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's termination because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations.
+- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation. Supported Linux hosts wrap the original terminal argv directly in the same user-systemd scope model as ordinary commands, preserving the node-pty PID, session leader, controlling terminal, and foreground-input inspection while the scope owns reparented or `setsid` descendants. Fallback hosts sweep observable descendants before and after terminating the top-level shell; exact pid/start identities prevent cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes start identities with zero-time process-handle waits for liveness, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's termination because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations.
 - **Terminate-and-join disposal** — the service retains live handles so its own disposal can run each provider-owned termination procedure and await its exit; quiescent and spawn-failed handles leave the live set after managed-range or terminal-session cleanup finishes.
-- **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener synchronously signals every ordinary managed range and observable terminal session still in the live sets. Linux issues the scope KILL request; the Windows runner treats parent IPC disconnect as Job termination; fallback and terminal paths retain their PGID, `taskkill`, and captured-identity behavior. The listener creates no promise or timer, preserves the host exit code and diagnostic, contains each target failure, and does not claim quiescence. Normal disposal keeps the awaited managed-range path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md).
+- **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener synchronously signals every ordinary managed range and terminal session still in the live sets. Linux native owners issue the scope KILL request; the Windows runner treats parent IPC disconnect as Job termination; fallback owners retain their PGID, `taskkill`, and captured-identity behavior. The listener creates no promise or timer, preserves the host exit code and diagnostic, contains each target failure, and does not claim quiescence. Normal disposal keeps the awaited managed-range path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md).
 
 ## Model Experience
 
@@ -27,10 +27,10 @@ No direct invalidation; the named consumers own any request-prefix changes.
 ## Known Limitations and Deferred Work
 
 - **Native ownership has explicit host requirements** — Linux needs a readable user manager and `systemd-run --expand-environment=no`; older systemd versions use the warned PGID fallback. macOS always uses that fallback because no supported public persistent owner exists.
-- **Native launch has a synchronous setup cost** — the first ordinary spawn probes host capability once for that provider instance, with a 5-second bound on each probe command. The local native path publishes a numeric target pid before returning, so each launch waits synchronously for its per-spawn runner to report target start or spawn failure. The built runner normally completes this handshake promptly; a runner that never publishes a result holds the caller for the fixed 10-second protocol bound. Each supported native command keeps one runner process alive until the OS-owned range is empty, and Windows additionally creates private per-spawn named-pipe endpoints. After publication, runner events are polled asynchronously every 100 ms and Linux scope state every 200 ms.
+- **Native launch has a synchronous setup cost** — every eligible ordinary or terminal spawn probes host capability before executing the user command, with a 5-second bound on each probe command; only the weaker-path warning is cached per provider. Each native ordinary launch waits synchronously for its per-spawn runner to report target start or spawn failure before publishing a numeric target pid. The built runner normally completes this handshake promptly; a runner that never publishes a result holds the caller for the fixed 10-second protocol bound. Each supported native ordinary command keeps one runner process alive until the OS-owned range is empty, and Windows additionally creates private per-spawn named-pipe endpoints. Linux terminal launch passes the scoped argv directly to `node-pty` and adds no runner. After publication, runner events are polled asynchronously every 100 ms and Linux scope state every 200 ms.
 - **Windows Job inheritance has defined exclusions** — ordinary descendants inherit the Job by default, but breakaway processes are outside the guarantee. The target starts only after Job assignment; external termination of the runner in the narrow create-to-assignment interval can leave a suspended target behind.
 - **Windows terminal signalling is console-wide** — SIGINT is delivered as a `\x03` Ctrl-C input write that conhost turns into a console-wide CTRL_C event; SIGTSTP and SIGHUP are rejected as unavailable; a `taskkill` without `/F` does not terminate console processes, so the teardown TERM tier is a grace wait before the `/F` escalation. Windows readiness has no exact stdin-wait tier: the prompt-marker fast path compares the shell pid as the pseudo foreground group, and silence/timing tiers cover the rest.
-- **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the `node-pty` root; on Linux, a child that calls `setsid` leaves both the tree and owned terminal session. The local provider does not add a continuous process-table monitor.
+- **Fallback terminal ownership remains observational** — on macOS or Linux without usable user-systemd, a child that reparents before any foreground-inspection snapshot or leaves the owned terminal session can escape the process-table scan. The local provider does not add a continuous process-table monitor; supported Linux native mode instead retains these descendants through scope membership.
 - **In-process cleanup requires a JavaScript-observable exit** — direct `process.exit()`, default uncaught exceptions, and default unhandled rejections emit Node's synchronous `exit` event. The default OS disposition for an unhandled `SIGTERM`, `SIGINT`, or `SIGHUP` bypasses that event; an application covers those signals only by installing a handler that performs normal disposal or calls `process.exit()`. `SIGKILL`, fatal OOM, `process.abort()`, native crashes, power loss, and any failure that cannot run JavaScript require an external supervisor, container init, or equivalent OS owner.
 - **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSPHRASE*`) pass through, and a whitelist for over-scrubbed vars is noted future work.
 - **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind.

+ 6 - 6
packages/subprocess/subprocess-local/README.zh.md

@@ -2,19 +2,19 @@
 
 [English](README.md) | 中文
 
-[`@deepseek-ai/dsh-subprocess`](../subprocess/README.zh.md) seam 的本地 Service Provider。`LocalSubprocessRuntime` 解析本地可执行文件,在宿主支持时为普通 Linux 与 Windows 命令建立 OS-owned managed range,并通过 `node-pty` 加平台进程检查实现终端进程。该实现没有任何配置:每项处置方式、限制、终端尺寸、宽限期与目录都来自调用方能力 seam([`dsh-bash-local`](../../shell/bash-local/README.zh.md)、[`dsh-lsp-stdio`](../../lsp/lsp-stdio/README.zh.md) 和 [`dsh-terminal-bash`](../../terminal/terminal-bash/README.zh.md))。
+[`@deepseek-ai/dsh-subprocess`](../subprocess/README.zh.md) seam 的本地 Service Provider。`LocalSubprocessRuntime` 解析本地可执行文件,在宿主支持时为普通 Linux 与 Windows 命令以及 Linux terminal session 建立 OS-owned managed range,并通过 `node-pty` 加平台进程检查实现终端进程。该实现没有任何配置:每项处置方式、限制、终端尺寸、宽限期与目录都来自调用方能力 seam([`dsh-bash-local`](../../shell/bash-local/README.zh.md)、[`dsh-lsp-stdio`](../../lsp/lsp-stdio/README.zh.md) 和 [`dsh-terminal-bash`](../../terminal/terminal-bash/README.zh.md))。
 
 ## 行为
 
-- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope。Windows parent 为非继承流创建 private named-pipe endpoint;runner 只打开 target 侧 handle,以 suspended 状态创建目标,把它分配给自身的 kill-on-close Job,恢复目标,发布启动事实,然后关闭这些 pipe handle。只有 runner 保留原始 target process handle 与 Job,报告 direct result,并只在 `ActiveProcesses` 归零后成功退出;parent 不打开这两个 native object。Linux scope 与 POSIX 进程组 fallback 先发送 TERM,并在 `graceMs` 后发送 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`waitForExit()` 只在所选 owner 证明范围为空后成功,无法取得该证明时则拒绝。direct result 到达后,`.done` 会等待所有非继承输出流关闭,最长不超过 `graceMs`;到达该界限时仅强制关闭 collected stream,raw pipe 仍归调用方所有。
+- **signal 与 wait 使用同一个 managed range**:Linux ordinary command 与 terminal session 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope。Windows parent 为非继承流创建 private named-pipe endpoint;runner 只打开 target 侧 handle,以 suspended 状态创建目标,把它分配给自身的 kill-on-close Job,恢复目标,发布启动事实,然后关闭这些 pipe handle。只有 runner 保留原始 target process handle 与 Job,报告 direct result,并只在 `ActiveProcesses` 归零后成功退出;parent 不打开这两个 native object。Linux scope 与 POSIX 进程组 fallback 先发送 TERM,并在 `graceMs` 后发送 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`waitForExit()` 只在所选 owner 证明范围为空后成功,无法取得该证明时则拒绝。direct result 到达后,`.done` 会等待所有非继承输出流关闭,最长不超过 `graceMs`;到达该界限时仅强制关闭 collected stream,raw pipe 仍归调用方所有。
 - **明确披露较弱 fallback**:macOS、旧版或不可用的 user-systemd,以及不可用的 Windows native 支持继续使用既有 detached PGID 或 `taskkill /T` 路径。provider 会在首个受影响命令前只告警一次。native runner 可能已经启动用户命令后绝不通过 fallback 重试。
 - **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留用于诊断的尾部。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需创建、权限为 `0700` 的每进程目录之下。
 - **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md)。
 - **基于偏移量的读取**:收集模式的读取器按完整流的字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。
 - **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该 seam 处被拒绝,相对 PATH 条目从宿主进程 cwd 解析。
-- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 启动身份与进程句柄零时等待结合起来判断存活状态,并把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组)。拆卸会验证 shell 已终止,因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。
+- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作。受支持的 Linux 宿主会把原始 terminal argv 直接包装进与 ordinary command 相同的 user-systemd scope,在保留 node-pty PID、session leader、控制终端与前台输入检查的同时,由 scope 持有已 reparent 或调用 `setsid` 的后代。fallback 宿主则在终止顶层 shell 前后清理可观察后代;精确的 pid/start 身份会防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 启动身份与进程句柄零时等待结合起来判断存活状态,并把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组)。拆卸会验证 shell 已终止,因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。
 - **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,使自身的 dispose 能执行每个 provider-owned termination procedure 并等待其退出;完全停稳与 spawn 失败的句柄会在 managed range 或 terminal session 清理完成后离开存活集合。
-- **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会同步向存活集合中的每个普通 managed range 与可观察 terminal session 发信号。Linux 发出 scope KILL 请求;Windows runner 把 parent IPC 断开视为 Job 终止;fallback 与 terminal 路径保留 PGID、`taskkill` 和 captured-identity 行为。listener 不创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待 managed-range 路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)。
+- **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会同步向存活集合中的每个普通 managed range 与 terminal session 发信号。Linux native owner 发出 scope KILL 请求;Windows runner 把 parent IPC 断开视为 Job 终止;fallback owner 保留 PGID、`taskkill` 和 captured-identity 行为。listener 不创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待 managed-range 路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)。
 
 ## 模型体验
 
@@ -27,10 +27,10 @@
 ## 已知限制与暂缓事项
 
 - **native ownership 有明确宿主条件**:Linux 需要可读的 user manager 与 `systemd-run --expand-environment=no`;旧版 systemd 使用带告警的 PGID fallback。macOS 因没有受支持的公开 persistent owner,始终使用该 fallback。
-- **native launch 有同步 setup 成本**:首条 ordinary spawn 会为该 provider instance 探测一次宿主能力,每条 probe command 的上限为 5 秒。本地 native 路径会在返回前发布数值 target pid,因此每次 launch 都会同步等待 per-spawn runner 报告 target start 或 spawn failure。built runner 通常会迅速完成该握手;若 runner 始终不发布结果,调用方会等待固定的 10 秒 protocol bound。每条受支持的 native command 都会保留一个 runner process,直到 OS-owned range 为空;Windows 还会创建 private per-spawn named-pipe endpoint。handle 发布后,runner event 每 100 ms、Linux scope state 每 200 ms 异步轮询。
+- **native launch 有同步 setup 成本**:每次符合条件的 ordinary 或 terminal spawn 都会在执行用户命令前探测宿主能力,每条 probe command 的上限为 5 秒;每个 provider 只缓存较弱路径的告警。每次 native ordinary launch 都会同步等待 per-spawn runner 报告 target start 或 spawn failure,再发布数值 target pid。built runner 通常会迅速完成该握手;若 runner 始终不发布结果,调用方会等待固定的 10 秒 protocol bound。每条受支持的 native ordinary command 都会保留一个 runner process,直到 OS-owned range 为空;Windows 还会创建 private per-spawn named-pipe endpoint。Linux terminal launch 会把 scoped argv 直接交给 `node-pty`,不增加 runner。handle 发布后,runner event 每 100 ms、Linux scope state 每 200 ms 异步轮询。
 - **Windows Job inheritance 有明确排除项**:普通 descendant 默认继承 Job,但 breakaway process 不在保证范围。目标只在 Job 分配后启动;runner 若在 create-to-assignment 极窄区间遭外力终止,可能留下 suspended target。
 - **Windows 终端信号是控制台级的**:SIGINT 以 `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 被拒绝(不可用);不带 `/F` 的 `taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。Windows 就绪没有精确的 stdin-wait 档:prompt-marker 快路径把 shell pid 作为伪前台进程组比较,其余由静默/计时档覆盖。
-- **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 `node-pty` 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器
+- **fallback terminal ownership 仍依赖观察**:在 macOS 或缺少可用 user-systemd 的 Linux 上,子进程如果在任何前台检查快照之前重新设定父进程,或离开自有 terminal session,就可能逃出进程表扫描。本地 provider 不会新增持续进程表 monitor;受支持的 Linux native mode 改由 scope membership 持有这些后代
 - **进程内清理要求退出阶段仍能执行 JavaScript**:直接 `process.exit()`、默认未捕获异常和默认未处理 rejection 会发出 Node 同步 `exit` 事件。未安装 handler 时,`SIGTERM`、`SIGINT` 或 `SIGHUP` 的默认 OS 处置不会发出该事件;应用只有安装执行正常 dispose 或调用 `process.exit()` 的 handler 才能覆盖这些信号。`SIGKILL`、fatal OOM、`process.abort()`、native crash、断电,以及任何无法运行 JavaScript 的故障,都需要外部 supervisor、容器 init 或等价的 OS 所有者负责。
 - **凭据清除依赖名称启发式规则**:只匹配 `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。
 - **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。

+ 1 - 1
packages/subprocess/subprocess-local/package.json

@@ -19,7 +19,7 @@
       "default": "./lib/index.js"
     },
     "./spawn-runner": {
-      "types": "./lib/types/spawn-runner.d.ts",
+      "types": "./lib/types/bin.d.ts",
       "default": "./lib/spawn-runner.js"
     },
     "./invariant": {

+ 12 - 0
packages/subprocess/subprocess-local/src/bin.ts

@@ -0,0 +1,12 @@
+#!/usr/bin/env node
+/** Thin process entry for the ordinary subprocess native runner. */
+
+import { reportSpawnRunnerFailure, runSpawnRunner } from './spawn-runner.ts'
+
+const argv = process.argv.slice(2)
+try {
+  await runSpawnRunner(argv)
+} catch (error: unknown) {
+  reportSpawnRunnerFailure(argv, error)
+  process.exitCode = 127
+}

+ 38 - 17
packages/subprocess/subprocess-local/src/index.ts

@@ -29,7 +29,7 @@ import {
   validateSubprocessSpec,
 } from './spawn.ts'
 import type { LocalSubprocessHandle, SpawnInternals } from './spawn.ts'
-import { launchLinuxScope, probeLinuxScope } from './linux-scope.ts'
+import { launchLinuxScope, prepareLinuxTerminalScope, probeLinuxScope } from './linux-scope.ts'
 import { launchWindowsJob, probeWindowsJob } from './windows-job.ts'
 import { createProcessInspector } from './process-inspector.ts'
 import type { ProcessInspector } from './process-inspector.ts'
@@ -49,8 +49,8 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
   private terminals = new Set<LocalTerminalHandle>()
   /** Test hook: spill and platform knobs forwarded to spawnSubprocess. */
   internals: SpawnInternals = {}
-  /** Ordinary native containment mode, selected once before its first user command. */
-  private ordinaryMode: 'linux-scope' | 'windows-job' | 'fallback' | undefined
+  /** Provider-lifetime latch suppressing repeated weaker-containment warnings. */
+  private fallbackWarningIssued = false
   /** Test hook for platform process inspection; production resolves lazily on terminal spawn. */
   terminalInspector: ProcessInspector | undefined
 
@@ -159,13 +159,13 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
 
   spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
     validateSubprocessSpec(spec)
-    const mode = this.selectOrdinaryMode()
+    const containmentMode = this.selectContainmentMode('ordinary')
     let handle: LocalSubprocessHandle
-    if (mode === 'fallback') {
+    if (containmentMode === 'fallback') {
       handle = spawnSubprocess(spec, this.internals)
     } else {
       const binding = prepareManagedProcessBinding(this.internals)
-      const launch = mode === 'linux-scope' ? launchLinuxScope(spec) : launchWindowsJob(spec)
+      const launch = containmentMode === 'linux-scope' ? launchLinuxScope(spec) : launchWindowsJob(spec)
       handle = bindManagedProcess(spec, launch, binding)
     }
     this.live.add(handle)
@@ -179,23 +179,27 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
     return handle
   }
 
-  private selectOrdinaryMode(): 'linux-scope' | 'windows-job' | 'fallback' {
-    if (this.ordinaryMode !== undefined) return this.ordinaryMode
+  private selectContainmentMode(
+    kind: 'ordinary' | 'terminal',
+  ): 'linux-scope' | 'windows-job' | 'fallback' {
     const platform = this.internals.platform ?? process.platform
-    if (platform === 'linux' && probeLinuxScope()) this.ordinaryMode = 'linux-scope'
-    else if (platform === 'win32' && probeWindowsJob()) this.ordinaryMode = 'windows-job'
-    else this.ordinaryMode = 'fallback'
-    if (this.ordinaryMode === 'fallback') this.warnFallback(platform)
-    return this.ordinaryMode
+    if (platform === 'linux' && probeLinuxScope()) return 'linux-scope'
+    if (kind === 'ordinary' && platform === 'win32' && probeWindowsJob()) return 'windows-job'
+    this.warnFallback(platform, kind)
+    return 'fallback'
   }
 
-  private warnFallback(platform: NodeJS.Platform): void {
+  private warnFallback(platform: NodeJS.Platform, kind: 'ordinary' | 'terminal'): void {
+    if (this.fallbackWarningIssued) return
+    this.fallbackWarningIssued = true
     const reason = platform === 'darwin'
       ? 'macOS has no supported persistent process-range owner'
       : platform === 'linux'
         ? 'a modern readable user-systemd scope is unavailable'
         : platform === 'win32'
-          ? 'the Win32 Job runner is unavailable'
+          ? kind === 'terminal'
+            ? 'Windows ConPTY remains outside Job containment'
+            : 'the Win32 Job runner is unavailable'
           : `platform ${platform} has no native managed range`
     this.ctx.logger.warn(
       `subprocess-local is using weaker process-tree containment because ${reason}; descendants that escape the process group or direct-parent tree are not guaranteed to terminate or delay waitForExit()`,
@@ -218,8 +222,25 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
       env: childEnv(spec.env),
     }
     const inspector = this.terminalInspector ?? createProcessInspector()
-    const terminal = nodePty.spawn(file, [...spec.argv.slice(1)], options)
-    const handle = new LocalTerminalHandle(terminal, inspector, spec.graceMs)
+    const containmentMode = this.selectContainmentMode('terminal')
+    const scope = containmentMode === 'linux-scope'
+      ? prepareLinuxTerminalScope(spec.argv)
+      : undefined
+    const terminal = nodePty.spawn(
+      scope?.command ?? file,
+      scope?.args ?? [...spec.argv.slice(1)],
+      options,
+    )
+    // oxlint-disable-next-line eslint/prefer-const -- The owner can query readiness before the handle is published.
+    let handle: LocalTerminalHandle | undefined
+    const owner = scope?.bindOwner(() => handle?.running ?? true)
+    handle = new LocalTerminalHandle(
+      terminal,
+      inspector,
+      spec.graceMs,
+      this.internals.platform ?? process.platform,
+      owner,
+    )
     this.terminals.add(handle)
     const release = async (): Promise<void> => {
       await handle.terminate()

+ 88 - 28
packages/subprocess/subprocess-local/src/linux-scope.ts

@@ -2,7 +2,6 @@
 
 import { randomBytes } from 'node:crypto'
 import { execFile, spawn, spawnSync } from 'node:child_process'
-import type { ChildProcess } from 'node:child_process'
 import { setTimeout as sleepMs } from 'node:timers/promises'
 import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts'
@@ -16,6 +15,7 @@ import {
   runnerStdio,
   spawnRunnerInvocation,
 } from './runner-launch.ts'
+import { cleanupRunnerFiles } from './runner-protocol.ts'
 
 /** Test seams for systemd command execution. */
 export interface LinuxScopeInternals {
@@ -83,18 +83,27 @@ export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean {
     timeout,
   })
   if (manager.error !== undefined || manager.status !== 0) return false
+  const runner = runSync(runnerCommand, [...runnerPrefix, '--mode', 'probe-node'], {
+    env: childEnv(),
+    stdio: 'ignore',
+    timeout,
+  })
+  if (runner.error !== undefined || runner.status !== 0) return false
+  const unitBase = unitStem('dsh-subprocess-probe')
   const probe = runSync(systemdRun, [
     '--user',
     '--scope',
     '--quiet',
     '--collect',
     '--expand-environment=no',
-    `--unit=${unitStem('dsh-subprocess-probe')}`,
+    `--unit=${unitBase}`,
     '--',
-    runnerCommand,
-    ...runnerPrefix,
-    '--mode',
-    'probe-node',
+    systemctl,
+    '--user',
+    'show',
+    `${unitBase}.scope`,
+    '--property=ActiveState',
+    '--value',
   ], {
     env: childEnv(),
     stdio: 'ignore',
@@ -113,7 +122,7 @@ class SystemdScopeOwner implements BoundProcessOwner {
     private readonly systemctl: string,
     private readonly runSync: typeof spawnSync,
     private readonly query: (command: string, args: readonly string[]) => Promise<SystemctlResult>,
-    private readonly runner: ChildProcess,
+    private readonly launcherRunning: () => boolean,
     private readonly onForceKillAttempt: () => void,
   ) {}
 
@@ -149,7 +158,7 @@ class SystemdScopeOwner implements BoundProcessOwner {
     const output = `${result.stdout}\n${result.stderr}`
     if (result.status !== 0) {
       if (MISSING_UNIT.test(output)) {
-        if (this.runner.pid === undefined || this.runner.exitCode !== null || this.runner.signalCode !== null) return false
+        if (!this.launcherRunning()) return false
       } else {
         if (result.error !== undefined) throw result.error
         throw new Error(`systemctl could not read ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`)
@@ -175,6 +184,51 @@ class SystemdScopeOwner implements BoundProcessOwner {
   }
 }
 
+/** Prepared node-pty argv plus the owner for the exact transient scope it enters. */
+export interface LinuxTerminalScopeLaunch {
+  command: string
+  args: string[]
+  bindOwner(launcherRunning: () => boolean): BoundProcessOwner
+}
+
+/**
+ * Wrap one terminal argv directly in a transient user-systemd scope.
+ * @param argv - original terminal command and arguments.
+ * @param internals - injected systemd commands used by tests.
+ * @returns the node-pty command, literal arguments, and owner binding for the same unit.
+ */
+export function prepareLinuxTerminalScope(
+  argv: readonly string[],
+  internals: LinuxScopeInternals = {},
+): LinuxTerminalScopeLaunch {
+  const runSync = internals.spawnSync ?? spawnSync
+  const query = internals.systemctlQuery ?? querySystemctl
+  const systemdRun = internals.systemdRun ?? 'systemd-run'
+  const systemctl = internals.systemctl ?? 'systemctl'
+  const unitBase = unitStem('dsh-terminal')
+  return {
+    command: systemdRun,
+    args: [
+      '--user',
+      '--scope',
+      '--quiet',
+      '--collect',
+      '--expand-environment=no',
+      `--unit=${unitBase}`,
+      '--',
+      ...argv,
+    ],
+    bindOwner: launcherRunning => new SystemdScopeOwner(
+      `${unitBase}.scope`,
+      systemctl,
+      runSync,
+      query,
+      launcherRunning,
+      () => {},
+    ),
+  }
+}
+
 /**
  * Launch one direct command inside a transient user scope.
  * @param spec - exact target argv, cwd, stdio, environment, and lifecycle settings.
@@ -193,25 +247,31 @@ export function launchLinuxScope(
   const invocation = internals.runnerInvocation ?? spawnRunnerInvocation()
   const files = runnerFiles(spec)
   const unitBase = unitStem('dsh-subprocess')
-  const child = run(systemdRun, [
-    '--user',
-    '--scope',
-    '--quiet',
-    '--collect',
-    '--expand-environment=no',
-    `--unit=${unitBase}`,
-    '--',
-    ...invocation,
-    '--mode',
-    'node',
-    '--request',
-    files.requestPath,
-    '--events',
-    files.eventsPath,
-  ], {
-    env: childEnv(),
-    stdio: runnerStdio(spec),
-  })
+  let child: ReturnType<typeof spawn>
+  try {
+    child = run(systemdRun, [
+      '--user',
+      '--scope',
+      '--quiet',
+      '--collect',
+      '--expand-environment=no',
+      `--unit=${unitBase}`,
+      '--',
+      ...invocation,
+      '--mode',
+      'node',
+      '--request',
+      files.requestPath,
+      '--events',
+      files.eventsPath,
+    ], {
+      env: childEnv(),
+      stdio: runnerStdio(spec),
+    })
+  } catch (error) {
+    cleanupRunnerFiles(files)
+    throw error
+  }
   const lifecycle = observeChildLifecycle(child)
   let forceKillAttempted = false
   const owner = new SystemdScopeOwner(
@@ -219,7 +279,7 @@ export function launchLinuxScope(
     systemctl,
     runSync,
     query,
-    child,
+    () => child.pid !== undefined && child.exitCode === null && child.signalCode === null,
     () => { forceKillAttempted = true },
   )
   const result = runnerDirectResult(child, files, lifecycle.exited)

+ 2 - 1
packages/subprocess/subprocess-local/src/managed-owner.ts

@@ -22,7 +22,7 @@ export interface ManagedProcessLaunch {
   stdin: Writable | null
   stdout: Readable | null
   stderr: Readable | null
-  pid: number
+  pid: number | undefined
   direct: Promise<SubprocessOutcome>
   owner: BoundProcessOwner
 }
@@ -70,6 +70,7 @@ export async function waitWithAbort(pending: Promise<void>, signal?: AbortSignal
   const aborted = Promise.withResolvers<boolean>()
   const onAbort = (): void => { aborted.resolve(false) }
   signal.addEventListener('abort', onAbort, { once: true })
+  if (signal.aborted) onAbort()
   try {
     return await Promise.race([pending.then(() => true), aborted.promise])
   } finally {

+ 31 - 11
packages/subprocess/subprocess-local/src/runner-launch.ts

@@ -37,7 +37,7 @@ export function spawnRunnerInvocation(): RunnerInvocation {
     return [process.execPath, builtEntry]
   }
   /* v8 ignore stop */
-  const sourceEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/src/spawn-runner.ts'))
+  const sourceEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/src/bin.ts'))
   return [process.execPath, '--import', 'tsx/esm', sourceEntry]
 }
 
@@ -68,11 +68,25 @@ export function runnerFiles(spec: SubprocessSpawnSpec): RunnerFiles {
 }
 
 interface RunnerHandshake {
-  pid: number
+  pid: number | undefined
   events: RunnerEvent[]
   failureReported: boolean
 }
 
+function directTerminalResult(
+  events: readonly RunnerEvent[],
+): { outcome: SubprocessOutcome } | { error: Error } | undefined {
+  for (const event of events) {
+    if (event.type === 'exit') {
+      return { outcome: { exitCode: event.exitCode, signal: event.signal } }
+    }
+    if (event.type === 'spawn-error' || event.type === 'runner-error') {
+      return { error: deserializeSpawnError(event.error) }
+    }
+  }
+  return undefined
+}
+
 /** Observe wrapper death without waiting for Node's blocked event loop to emit close. */
 function runnerExited(child: ChildProcess, pid: number): boolean {
   if (child.exitCode !== null || child.signalCode !== null) return true
@@ -105,7 +119,7 @@ function waitForRunnerHandshake(child: ChildProcess, files: RunnerFiles): Runner
     const terminal = events.find(event => event.type === 'started' || event.type === 'spawn-error' || event.type === 'runner-error')
     if (terminal?.type === 'started') return { pid: terminal.pid, events, failureReported: false }
     if (terminal?.type === 'spawn-error' || terminal?.type === 'runner-error') {
-      return { pid: -1, events, failureReported: true }
+      return { pid: undefined, events, failureReported: true }
     }
     if (child.pid === undefined) throw new Error('native subprocess runner failed to start')
     if (runnerExited(child, child.pid)) throw new Error('native subprocess runner exited before reporting target start')
@@ -119,7 +133,7 @@ async function waitForDirectResult(
   initial: RunnerEvent[],
   exited: Promise<void>,
 ): Promise<SubprocessOutcome> {
-  let seen = 0
+  let seen = initial.length
   const wrapperState = { exited: false }
   void exited.then(() => { wrapperState.exited = true })
   for (;;) {
@@ -128,11 +142,12 @@ async function waitForDirectResult(
     // event was written before the runner exited.
     const exitedBeforeRead = wrapperState.exited
     const events = await readRunnerEventsAsync(files.eventsPath)
-    for (const event of events.slice(seen)) {
-      if (event.type === 'exit') return { exitCode: event.exitCode, signal: event.signal }
-      if (event.type === 'spawn-error' || event.type === 'runner-error') throw deserializeSpawnError(event.error)
+    const terminal = directTerminalResult(events.slice(seen))
+    if (terminal !== undefined) {
+      if ('error' in terminal) throw terminal.error
+      return terminal.outcome
     }
-    seen = Math.max(seen, events.length, initial.length)
+    seen = Math.max(seen, events.length)
     if (exitedBeforeRead) {
       throw new DirectResultUnavailableError('native subprocess runner exited without a direct-command result')
     }
@@ -152,7 +167,7 @@ export function runnerDirectResult(
   files: RunnerFiles,
   exited: Promise<void>,
 ): {
-  pid: number
+  pid: number | undefined
   direct: Promise<SubprocessOutcome>
   failureReported: boolean
 } {
@@ -161,11 +176,16 @@ export function runnerDirectResult(
     handshake = waitForRunnerHandshake(child, files)
   } catch (error) {
     cleanupRunnerFiles(files)
-    return { pid: -1, direct: Promise.resolve().then(() => { throw error }), failureReported: false }
+    return { pid: undefined, direct: Promise.resolve().then(() => { throw error }), failureReported: false }
   }
+  const terminal = directTerminalResult(handshake.events)
   return {
     pid: handshake.pid,
-    direct: waitForDirectResult(files, handshake.events, exited),
+    direct: terminal === undefined
+      ? waitForDirectResult(files, handshake.events, exited)
+      : 'error' in terminal
+        ? Promise.reject(terminal.error)
+        : Promise.resolve(terminal.outcome),
     failureReported: handshake.failureReported,
   }
 }

+ 128 - 63
packages/subprocess/subprocess-local/src/spawn-runner.ts

@@ -7,7 +7,7 @@ import {
   loadWin32ProcessBindings,
   openNamedPipeForStdio,
   pollProcessExit,
-  spawnOrdinaryJobProcess,
+  spawnCurrentTokenJobProcess,
   terminateJob,
   waitForProcessExit,
   Win32Error,
@@ -33,6 +33,35 @@ type RunnerArgs =
     stderrPipe?: string
   }
 
+type RunnerHost = Pick<
+  NodeJS.Process,
+  'env' | 'exitCode' | 'connected' | 'cwd' | 'chdir' | 'on' | 'off' | 'disconnect'
+>
+
+interface RunnerInternals {
+  spawn: typeof spawn
+  loadWin32ProcessBindings: typeof loadWin32ProcessBindings
+  openNamedPipeForStdio: typeof openNamedPipeForStdio
+  spawnCurrentTokenJobProcess: typeof spawnCurrentTokenJobProcess
+  pollProcessExit: typeof pollProcessExit
+  isJobEmpty: typeof isJobEmpty
+  terminateJob: typeof terminateJob
+  waitForProcessExit: typeof waitForProcessExit
+  closeHandleChecked: typeof closeHandleChecked
+}
+
+const defaultRunnerInternals: RunnerInternals = {
+  spawn,
+  loadWin32ProcessBindings,
+  openNamedPipeForStdio,
+  spawnCurrentTokenJobProcess,
+  pollProcessExit,
+  isJobEmpty,
+  terminateJob,
+  waitForProcessExit,
+  closeHandleChecked,
+}
+
 function parseArgs(argv: string[]): RunnerArgs {
   let mode: string | undefined
   let requestPath: string | undefined
@@ -89,49 +118,68 @@ function win32SpawnError(error: unknown, request: RunnerRequest): SerializedSpaw
   }
 }
 
-function runNode(request: RunnerRequest, eventsPath: string): void {
+async function runNode(
+  request: RunnerRequest,
+  eventsPath: string,
+  host: RunnerHost,
+  internals: RunnerInternals,
+): Promise<void> {
+  const ignoreScopeSignal = (): void => { /* The target receives the scope signal; the runner reports its outcome. */ }
   for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'] as const) {
-    process.on(signal, () => { /* The scope target receives it; the runner stays to report direct outcome. */ })
+    host.on(signal, ignoreScopeSignal)
   }
   const [program, ...args] = request.argv
-  const child = spawn(program as string, args, {
+  const child = internals.spawn(program as string, args, {
     cwd: request.cwd,
     env: request.env,
     stdio: 'inherit',
   })
-  let started = false
-  let failed = false
-  child.once('spawn', () => {
-    started = true
-    appendRunnerEvent(eventsPath, { type: 'started', pid: child.pid as number })
-  })
-  child.once('error', (error) => {
-    failed = true
-    if (!started) appendRunnerEvent(eventsPath, { type: 'spawn-error', error: serializeSpawnError(error) })
-    else appendRunnerEvent(eventsPath, { type: 'runner-error', error: serializeSpawnError(error) })
-    process.exitCode = 127
-  })
-  child.once('exit', (exitCode, signal) => {
-    if (failed) return
-    appendRunnerEvent(eventsPath, { type: 'exit', exitCode, signal })
-    process.exitCode = exitCode ?? 1
+  await new Promise<void>((resolve) => {
+    let started = false
+    let failed = false
+    let settled = false
+    const finish = (): void => {
+      if (settled) return
+      settled = true
+      for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'] as const) host.off(signal, ignoreScopeSignal)
+      resolve()
+    }
+    child.once('spawn', () => {
+      started = true
+      appendRunnerEvent(eventsPath, { type: 'started', pid: child.pid as number })
+    })
+    child.once('error', (error) => {
+      failed = true
+      if (!started) appendRunnerEvent(eventsPath, { type: 'spawn-error', error: serializeSpawnError(error) })
+      else appendRunnerEvent(eventsPath, { type: 'runner-error', error: serializeSpawnError(error) })
+      host.exitCode = 127
+      finish()
+    })
+    child.once('exit', (exitCode, signal) => {
+      if (!failed) {
+        appendRunnerEvent(eventsPath, { type: 'exit', exitCode, signal })
+        host.exitCode = exitCode ?? 1
+      }
+      finish()
+    })
   })
 }
 
-function replaceEnvironment(env: Record<string, string>): void {
-  for (const key of Object.keys(process.env)) Reflect.deleteProperty(process.env, key)
-  Object.assign(process.env, env)
+function replaceEnvironment(target: NodeJS.ProcessEnv, env: Record<string, string>): void {
+  for (const key of Object.keys(target)) Reflect.deleteProperty(target, key)
+  Object.assign(target, env)
 }
 
 function closeStdioHandles(
   api: ReturnType<typeof loadWin32ProcessBindings>,
   handles: Array<{ handle: NativePtr; label: string }>,
   reportFailure: boolean,
+  internals: RunnerInternals,
 ): void {
   let failure: Error | undefined
   for (const owned of handles.splice(0)) {
     try {
-      closeHandleChecked(api, owned.handle, owned.label)
+      internals.closeHandleChecked(api, owned.handle, owned.label)
     } catch (error) {
       handles.push(owned)
       failure ??= error instanceof Error ? error : new Error(serializeSpawnError(error).message)
@@ -144,9 +192,11 @@ async function runWin32(
   request: RunnerRequest,
   eventsPath: string,
   pipes: Pick<Extract<RunnerArgs, { mode: 'win32' }>, 'stdinPipe' | 'stdoutPipe' | 'stderrPipe'>,
+  host: RunnerHost,
+  internals: RunnerInternals,
 ): Promise<void> {
-  replaceEnvironment(request.env)
-  const api = loadWin32ProcessBindings()
+  replaceEnvironment(host.env, request.env)
+  const api = internals.loadWin32ProcessBindings()
   let processHandle: NativePtr | undefined
   let jobHandle: NativePtr | undefined
   const openedStdio: Array<{ handle: NativePtr; label: string }> = []
@@ -158,27 +208,27 @@ async function runWin32(
       ['stderr', pipes.stderrPipe, 'write'],
     ] as const) {
       if (path === undefined) continue
-      const handle = openNamedPipeForStdio(api, path, access)
+      const handle = internals.openNamedPipeForStdio(api, path, access)
       stdio[key] = handle
       openedStdio.push({ handle, label: `ordinary target ${key} pipe` })
     }
     // Match Node's cwd-relative executable lookup and spawn-error attribution.
-    const runnerCwd = process.cwd()
-    process.chdir(request.cwd)
+    const runnerCwd = host.cwd()
+    host.chdir(request.cwd)
     try {
       const [command, ...args] = request.argv
-      const spawned = spawnOrdinaryJobProcess(
+      const spawned = internals.spawnCurrentTokenJobProcess(
         api,
-        { command: command as string, args, cwd: process.cwd() },
+        { command: command as string, args, cwd: host.cwd() },
         stdio,
       )
       processHandle = spawned.process
       jobHandle = spawned.job
       appendRunnerEvent(eventsPath, { type: 'started', pid: spawned.pid })
     } finally {
-      process.chdir(runnerCwd)
+      host.chdir(runnerCwd)
     }
-    closeStdioHandles(api, openedStdio, true)
+    closeStdioHandles(api, openedStdio, true, internals)
 
     await new Promise<void>((resolve, reject) => {
       let settled = false
@@ -187,8 +237,8 @@ async function runWin32(
         if (settled) return
         settled = true
         clearInterval(timer)
-        process.off('message', onMessage)
-        process.off('disconnect', onDisconnect)
+        host.off('message', onMessage)
+        host.off('disconnect', onDisconnect)
         if (error === undefined) resolve()
         else reject(error instanceof Error ? error : new Error(serializeSpawnError(error).message))
       }
@@ -196,7 +246,7 @@ async function runWin32(
         if (terminationRequested || jobHandle === undefined) return
         terminationRequested = true
         try {
-          terminateJob(api, jobHandle, 1)
+          internals.terminateJob(api, jobHandle, 1)
         } catch (error) {
           settle(error)
         }
@@ -207,20 +257,20 @@ async function runWin32(
         }
       }
       const onDisconnect = (): void => { terminate() }
-      process.on('message', onMessage)
-      process.on('disconnect', onDisconnect)
+      host.on('message', onMessage)
+      host.on('disconnect', onDisconnect)
       const timer = setInterval(() => {
         try {
           if (processHandle !== undefined) {
-            const exitCode = pollProcessExit(api, processHandle)
+            const exitCode = internals.pollProcessExit(api, processHandle)
             if (exitCode !== undefined) {
               appendRunnerEvent(eventsPath, { type: 'exit', exitCode, signal: null })
-              closeHandleChecked(api, processHandle, 'ordinary direct process')
+              internals.closeHandleChecked(api, processHandle, 'ordinary direct process')
               processHandle = undefined
             }
           }
-          if (processHandle === undefined && jobHandle !== undefined && isJobEmpty(api, jobHandle)) {
-            closeHandleChecked(api, jobHandle, 'ordinary process Job')
+          if (processHandle === undefined && jobHandle !== undefined && internals.isJobEmpty(api, jobHandle)) {
+            internals.closeHandleChecked(api, jobHandle, 'ordinary process Job')
             jobHandle = undefined
             settle()
           }
@@ -238,61 +288,76 @@ async function runWin32(
       type: targetSpawnFailed ? 'spawn-error' : 'runner-error',
       error: targetSpawnFailed ? win32SpawnError(error, request) : serializeSpawnError(error),
     })
-    if (!targetSpawnFailed) process.exitCode = 127
+    if (!targetSpawnFailed) host.exitCode = 127
   } finally {
-    closeStdioHandles(api, openedStdio, false)
+    closeStdioHandles(api, openedStdio, false, internals)
     if (processHandle !== undefined) {
-      try { closeHandleChecked(api, processHandle, 'ordinary direct process cleanup') } catch { /* best effort after reported failure */ }
+      try { internals.closeHandleChecked(api, processHandle, 'ordinary direct process cleanup') } catch { /* best effort after reported failure */ }
     }
     if (jobHandle !== undefined) {
-      try { closeHandleChecked(api, jobHandle, 'ordinary process Job cleanup') } catch { /* best effort after reported failure */ }
+      try { internals.closeHandleChecked(api, jobHandle, 'ordinary process Job cleanup') } catch { /* best effort after reported failure */ }
     }
   }
 }
 
-function probeWin32Job(): void {
-  const command = process.env.ComSpec ?? process.env.COMSPEC
+function probeWin32Job(host: RunnerHost, internals: RunnerInternals): void {
+  const command = host.env.ComSpec ?? host.env.COMSPEC
   if (command === undefined) throw new Error('subprocess runner cannot probe a Windows Job without ComSpec')
-  const api = loadWin32ProcessBindings()
-  const spawned = spawnOrdinaryJobProcess(api, {
+  const api = internals.loadWin32ProcessBindings()
+  const spawned = internals.spawnCurrentTokenJobProcess(api, {
     command,
     args: ['/d', '/s', '/c', 'exit 0'],
-    cwd: process.cwd(),
+    cwd: host.cwd(),
   })
   try {
-    const exitCode = waitForProcessExit(api, spawned.process)
+    const exitCode = internals.waitForProcessExit(api, spawned.process)
     if (exitCode !== 0) throw new Error(`subprocess Windows Job probe exited with code ${String(exitCode)}`)
   } finally {
-    closeHandleChecked(api, spawned.job, 'subprocess Windows Job probe')
+    internals.closeHandleChecked(api, spawned.job, 'subprocess Windows Job probe')
   }
 }
 
-async function main(): Promise<void> {
-  const args = parseArgs(process.argv.slice(2))
+/**
+ * Execute one parsed private-runner request.
+ * @param argv - runner arguments after the executable and entry path.
+ * @param host - process operations; tests provide an isolated host facade.
+ * @param internals - platform operations; tests replace native Win32 calls.
+ * @returns after the requested probe or target lifecycle completes.
+ */
+export async function runSpawnRunner(
+  argv: string[],
+  host: RunnerHost = process,
+  internals: RunnerInternals = defaultRunnerInternals,
+): Promise<void> {
+  const args = parseArgs(argv)
   if (args.mode === 'probe-node') return
   if (args.mode === 'probe-win32') {
-    probeWin32Job()
+    probeWin32Job(host, internals)
     return
   }
   const request = consumeRunnerRequest(args.requestPath)
-  if (args.mode === 'node') runNode(request, args.eventsPath)
+  if (args.mode === 'node') await runNode(request, args.eventsPath, host, internals)
   else {
     try {
-      await runWin32(request, args.eventsPath, args)
+      await runWin32(request, args.eventsPath, args, host, internals)
     } finally {
-      if (process.connected) process.disconnect()
+      if (host.connected) host.disconnect()
     }
   }
 }
 
-main().catch((error: unknown) => {
+/**
+ * Publish an infrastructure failure when runner arguments still identify an event file.
+ * @param argv - original runner arguments.
+ * @param error - uncaught runner failure.
+ */
+export function reportSpawnRunnerFailure(argv: string[], error: unknown): void {
   try {
-    const args = parseArgs(process.argv.slice(2))
+    const args = parseArgs(argv)
     if (args.mode !== 'probe-node' && args.mode !== 'probe-win32') {
       appendRunnerEvent(args.eventsPath, { type: 'runner-error', error: serializeSpawnError(error) })
     }
   } catch {
     // No trustworthy transport remains; the parent reports the missing result.
   }
-  process.exitCode = 127
-})
+}

+ 13 - 13
packages/subprocess/subprocess-local/src/spawn.ts

@@ -267,12 +267,12 @@ export class OutputCollector {
 /**
  * Send `sig` to a detached POSIX process group. Never throws: delivery races
  * process exit and may run in a timer callback, so failures are contained and
- * a non-positive pid is a no-op.
- * @param pid - the group leader's pid; non-positive means the spawn failed and the call is a no-op.
+ * a missing pid is a no-op.
+ * @param pid - the group leader's pid, when the spawn published one.
  * @param sig - the signal to deliver to the whole group.
  */
-export function killGroup(pid: number, sig: NodeJS.Signals): void {
-  if (pid <= 0) return
+export function killGroup(pid: number | undefined, sig: NodeJS.Signals): void {
+  if (pid === undefined) return
   try {
     process.kill(-pid, sig)
   } catch {
@@ -285,10 +285,10 @@ export function killGroup(pid: number, sig: NodeJS.Signals): void {
  * POSIX group signalling — delivery races tree exit, so an absent tree, a
  * nonzero status, or a missing taskkill binary must not break idempotent
  * teardown.
- * @param pid - root process id; non-positive is a no-op.
+ * @param pid - root process id, when the spawn published one.
  */
-export function taskkillProcessTree(pid: number): void {
-  if (pid <= 0) return
+export function taskkillProcessTree(pid: number | undefined): void {
+  if (pid === undefined) return
   // Outcome deliberately unchecked: an already-absent tree (status 128), exit
   // races, and a missing taskkill binary (spawnSync reports, never throws) are
   // as tolerable here as ESRCH is for a POSIX group signal.
@@ -303,17 +303,17 @@ export function taskkillProcessTree(pid: number): void {
  */
 function signalTree(
   platform: NodeJS.Platform,
-  pid: number,
+  pid: number | undefined,
   sig: NodeJS.Signals,
   child: ChildProcess,
   taskkill: (pid: number) => void,
 ): void {
+  /* v8 ignore next -- kill/terminate gate on treeAlive(), which is false without a pid; this guard protects direct callers only. */
+  if (pid === undefined) return
   if (platform === 'win32') {
     taskkill(pid)
     return
   }
-  /* v8 ignore next -- kill/terminate gate on treeAlive(), which is false for pid -1; this guard protects direct callers only. */
-  if (pid <= 0) return
   try {
     process.kill(-pid, sig)
   } catch {
@@ -367,7 +367,7 @@ function directChildResult(child: ChildProcess): Promise<SubprocessOutcome> {
 
 function fallbackOwner(
   platform: NodeJS.Platform,
-  pid: number,
+  pid: number | undefined,
   child: ChildProcess,
   taskkill: (pid: number) => void,
   linuxGroupHasLiveMembers: (processGroupId: number) => boolean | undefined,
@@ -382,7 +382,7 @@ function fallbackOwner(
   )
 
   const alive = (): boolean => {
-    if (stopped || pid <= 0) return false
+    if (stopped || pid === undefined) return false
     if (platform === 'win32') return child.exitCode === null && child.signalCode === null
     try {
       process.kill(-pid, 0)
@@ -601,7 +601,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
     detached: platform !== 'win32',
   })
   const direct = directChildResult(child)
-  const pid = child.pid ?? -1
+  const pid = child.pid
   const owner = fallbackOwner(
     platform,
     pid,

+ 44 - 1
packages/subprocess/subprocess-local/src/terminal.ts

@@ -10,6 +10,7 @@ import type {
   SubprocessTerminalHandle,
   SubprocessTerminalSignal,
 } from '@deepseek-ai/dsh-subprocess'
+import type { BoundProcessOwner } from './managed-owner.ts'
 import type { ProcessIdentity, ProcessInspector } from './process-inspector.ts'
 
 function delay(ms: number): Promise<void> {
@@ -25,7 +26,8 @@ function signalName(number: number | undefined): NodeJS.Signals | null {
 }
 
 /**
- * A local terminal whose process-session ownership stays below the PTY backend.
+ * A local terminal whose native managed range or fallback process-session
+ * ownership stays below the PTY backend.
  * The seam's terminate() promise — no write, inspection, or signal in flight
  * after settlement — holds here without operation tracking only because every
  * handle call completes synchronously under the hood (node-pty write, ps-based
@@ -57,6 +59,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
     private readonly inspector: ProcessInspector,
     private readonly graceMs: number,
     private readonly platform: NodeJS.Platform = process.platform,
+    private readonly managedOwner?: BoundProcessOwner,
   ) {
     this.pid = terminal.pid
     this.rootIdentity = inspector.processTree(this.pid).find(member => member.pid === this.pid)
@@ -73,6 +76,11 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
     })
   }
 
+  /** Whether node-pty has not yet published the top-level exit event. */
+  get running(): boolean {
+    return !this.exited
+  }
+
   // node-pty writes synchronously; the seam returns a promise for remote transports.
   // oxlint-disable-next-line typescript/require-await -- Preserve promise rejection semantics at the async provider contract.
   async write(data: string): Promise<void> {
@@ -130,6 +138,10 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
    * event. This does not claim quiescence and does not replace terminate().
    */
   terminateForHostExit(): void {
+    if (this.managedOwner !== undefined) {
+      this.managedOwner.signal('SIGKILL')
+      return
+    }
     this.forceStopDescendants()
     this.forceStopShell()
     this.forceStopDescendants()
@@ -291,6 +303,12 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
   }
 
   private async closeOnce(): Promise<void> {
+    if (this.managedOwner !== undefined) {
+      await this.closeManagedRange(this.managedOwner)
+      this.dataDisposable.dispose()
+      this.exitDisposable.dispose()
+      return
+    }
     let survivors = await this.stopDescendants()
     if (survivors.length > 0) {
       throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map(member => member.pid).join(', ')}`)
@@ -305,6 +323,31 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
     this.exitDisposable.dispose()
   }
 
+  private async closeManagedRange(owner: BoundProcessOwner): Promise<void> {
+    owner.signal('SIGTERM')
+    const observation = owner.waitForExit()
+    const first = await Promise.race([
+      observation.then(
+        () => ({ kind: 'stopped' as const }),
+        (error: unknown) => ({ kind: 'failed' as const, error }),
+      ),
+      delay(this.graceMs).then(() => ({ kind: 'timeout' as const })),
+    ])
+    if (first.kind !== 'stopped') {
+      owner.signal('SIGKILL')
+      if (first.kind === 'failed') {
+        // The observation failure is still authoritative, but force cleanup
+        // must be attempted before exposing it to the caller.
+        throw first.error
+      }
+      await observation
+    }
+    if (!this.exited) {
+      await Promise.race([this.done.then(() => undefined), delay(this.graceMs)])
+    }
+    if (!this.exited) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`)
+  }
+
   private settleExitIfGone(): void {
     // An externally taskkilled Windows shell may never fire node-pty's exit
     // notification (its console-list agent fails without a parent console),

+ 122 - 9
packages/subprocess/subprocess-local/tests/linux-scope.spec.ts

@@ -1,7 +1,9 @@
 import { spawn, spawnSync } from 'node:child_process'
+import { existsSync } from 'node:fs'
+import { dirname } from 'node:path'
 import { describe, expect, it, vi } from 'vitest'
 import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
-import { launchLinuxScope, probeLinuxScope } from '../src/linux-scope.ts'
+import { launchLinuxScope, prepareLinuxTerminalScope, probeLinuxScope } from '../src/linux-scope.ts'
 import { spawnRunnerInvocation } from '../src/runner-launch.ts'
 
 function spec(argv: string[]): SubprocessSpawnSpec {
@@ -46,24 +48,60 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', ()
         systemctl: 'systemctl',
         runnerInvocation,
       })).toBe(true)
-      expect(calls[1]).toContain('--expand-environment=no')
-      expect(calls[1]).not.toContain('--pipe')
-      expect(calls[1]).not.toContain('--wait')
-      const separator = calls[1]?.indexOf('--') ?? -1
-      expect(calls[1]?.slice(separator + 1)).toEqual([...runnerInvocation, '--mode', 'probe-node'])
+      expect(calls[1]).toEqual([...runnerInvocation, '--mode', 'probe-node'])
+      expect(calls[2]).toContain('--expand-environment=no')
+      expect(calls[2]).not.toContain('--pipe')
+      expect(calls[2]).not.toContain('--wait')
+      const unitArg = calls[2]?.find(arg => arg.startsWith('--unit='))
+      if (unitArg === undefined) throw new Error('scope probe did not publish its unit')
+      const separator = calls[2]?.indexOf('--') ?? -1
+      expect(calls[2]?.slice(separator + 1)).toEqual([
+        'systemctl',
+        '--user',
+        'show',
+        `${unitArg.slice('--unit='.length)}.scope`,
+        '--property=ActiveState',
+        '--value',
+      ])
       expect(environments[0]?.LC_ALL).toBe('C')
-      expect(environments[0]).not.toHaveProperty(secretName)
+      for (const environment of environments) expect(environment).not.toHaveProperty(secretName)
     } finally {
       if (previousSecret === undefined) Reflect.deleteProperty(process.env, secretName)
       else process.env[secretName] = previousSecret
     }
 
     const oldSystemd = vi.fn((command: string) => ({
-      status: command === 'systemctl' ? 0 : 1,
+      status: command === 'systemd-run' ? 1 : 0,
       error: undefined,
     })) as unknown as typeof spawnSync
     expect(probeLinuxScope({ spawnSync: oldSystemd })).toBe(false)
 
+    const failedRunner = vi.fn((command: string) => ({
+      status: command === 'node-runtime' ? 1 : 0,
+      error: undefined,
+    })) as unknown as typeof spawnSync
+    expect(probeLinuxScope({
+      spawnSync: failedRunner,
+      runnerInvocation: ['node-runtime', 'runner-entry.js'],
+    })).toBe(false)
+    expect(failedRunner).toHaveBeenCalledTimes(2)
+
+    let unreadableProbeCalls = 0
+    const unreadableScope = vi.fn(() => ({
+      status: ++unreadableProbeCalls === 3 ? 1 : 0,
+      error: undefined,
+    })) as unknown as typeof spawnSync
+    expect(probeLinuxScope({ spawnSync: unreadableScope })).toBe(false)
+
+    let erroredProbeCalls = 0
+    const erroredScope = vi.fn(() => {
+      erroredProbeCalls += 1
+      return erroredProbeCalls === 3
+        ? { status: null, error: new Error('scope read failed') }
+        : { status: 0, error: undefined }
+    }) as unknown as typeof spawnSync
+    expect(probeLinuxScope({ spawnSync: erroredScope })).toBe(false)
+
     const managerError = new Error('missing user manager')
     expect(probeLinuxScope({
       spawnSync: vi.fn(() => ({ error: managerError })) as unknown as typeof spawnSync,
@@ -74,6 +112,78 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', ()
 
   })
 
+  it('removes private runner files when systemd-run throws synchronously', () => {
+    const failure = new Error('systemd-run threw')
+    let requestPath: string | undefined
+    const run = vi.fn((_command: string, args: readonly string[]) => {
+      const requestIndex = args.indexOf('--request')
+      requestPath = args[requestIndex + 1]
+      throw failure
+    }) as unknown as typeof spawn
+
+    expect(() => launchLinuxScope(spec([process.execPath, '-e', '']), {
+      spawn: run,
+      runnerInvocation: spawnRunnerInvocation(),
+    })).toThrow(failure)
+    expect(requestPath).toBeDefined()
+    expect(existsSync(dirname(requestPath as string))).toBe(false)
+  })
+
+  it('wraps terminal argv literally and binds signalling and observation to the same scope', async () => {
+    const signalCalls: Array<[string, readonly string[]]> = []
+    const queryCalls: Array<[string, readonly string[]]> = []
+    const runSync = vi.fn((command: string, args: readonly string[]) => {
+      signalCalls.push([command, args])
+      return { status: 0, stdout: '', stderr: '', error: undefined }
+    }) as unknown as typeof spawnSync
+    const query = vi.fn(async (command: string, args: readonly string[]) => {
+      queryCalls.push([command, args])
+      return { status: 0, stdout: 'inactive\n', stderr: '' }
+    })
+    const argv = ['/bin/bash', '-c', 'printf "%s" "$HOME"']
+    const launch = prepareLinuxTerminalScope(argv, {
+      spawnSync: runSync,
+      systemdRun: '/usr/bin/systemd-run',
+      systemctl: '/usr/bin/systemctl',
+      systemctlQuery: query,
+    })
+    const unitArg = launch.args.find(arg => arg.startsWith('--unit='))
+    if (unitArg === undefined) throw new Error('terminal scope did not publish its unit')
+    const unit = `${unitArg.slice('--unit='.length)}.scope`
+
+    expect(launch.command).toBe('/usr/bin/systemd-run')
+    expect(launch.args.slice(0, -argv.length)).toEqual([
+      '--user',
+      '--scope',
+      '--quiet',
+      '--collect',
+      '--expand-environment=no',
+      unitArg,
+      '--',
+    ])
+    expect(launch.args.slice(-argv.length)).toEqual(argv)
+
+    const owner = launch.bindOwner(() => false)
+    owner.signal('SIGTERM')
+    owner.signal('SIGKILL')
+    await owner.waitForExit()
+
+    expect(signalCalls).toEqual([
+      [
+        '/usr/bin/systemctl',
+        ['--user', 'kill', '--kill-whom=all', '--signal=SIGTERM', unit],
+      ],
+      [
+        '/usr/bin/systemctl',
+        ['--user', 'kill', '--kill-whom=all', '--signal=SIGKILL', unit],
+      ],
+    ])
+    expect(queryCalls).toEqual([[
+      '/usr/bin/systemctl',
+      ['--user', 'show', unit, '--property=ActiveState', '--value'],
+    ]])
+  })
+
   it('keeps user argv out of systemd-run and reports the direct target outcome', async () => {
     let wrapper: ReturnType<typeof spawn> | undefined
     let systemdArgs: readonly string[] = []
@@ -267,7 +377,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', ()
       }),
       runnerInvocation: spawnRunnerInvocation(),
     })
-    expect(launch.pid).toBe(-1)
+    expect(launch.pid).toBeUndefined()
     await expect(launch.direct).rejects.toThrow('runner failed to start')
     await expect(launch.owner.waitForExit()).resolves.toBeUndefined()
   })
@@ -391,6 +501,9 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', ()
     try {
       const defaults = await import('../src/linux-scope.ts')
       expect(defaults.probeLinuxScope()).toBe(true)
+      const terminalLaunch = defaults.prepareLinuxTerminalScope(['shell', 'literal $HOME'])
+      expect(terminalLaunch.command).toBe('systemd-run')
+      expect(terminalLaunch.args.slice(-3)).toEqual(['--', 'shell', 'literal $HOME'])
       const launch = defaults.launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']))
       await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null })
       await expect(launch.owner.waitForExit()).resolves.toBeUndefined()

+ 105 - 19
packages/subprocess/subprocess-local/tests/local.spec.ts

@@ -380,6 +380,91 @@ describe('LocalSubprocessRuntime', () => {
     }
   })
 
+  it('wraps Linux terminals in the selected scope and binds owner liveness', async () => {
+    let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined
+    let launcherRunning: (() => boolean) | undefined
+    const terminal = {
+      pid: 123,
+      onData: () => ({ dispose: () => {} }),
+      onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => {
+        exitListener = listener
+        return { dispose: () => {} }
+      },
+      write: () => {},
+      kill: () => {},
+    }
+    const nodePtySpawn = vi.fn(() => terminal)
+    const owner = {
+      signal: vi.fn(),
+      waitForExit: vi.fn(async () => {}),
+    }
+    const launcherStates: boolean[] = []
+    const bindOwner = vi.fn((running: () => boolean) => {
+      launcherRunning = running
+      launcherStates.push(running())
+      return owner
+    })
+    const prepareLinuxTerminalScope = vi.fn((argv: readonly string[]) => ({
+      command: '/usr/bin/systemd-run',
+      args: ['--user', '--scope', '--', ...argv],
+      bindOwner,
+    }))
+    const probeLinuxScope = vi.fn(() => true)
+    const inspector = {
+      foregroundPgid: () => undefined,
+      isStdinWaiting: () => false,
+      processTree: () => [{ pid: 123, started: 'shell' }],
+      processSession: () => [],
+      isAlive: () => false,
+      signalGroup: () => {},
+      signalProcess: () => {},
+    }
+
+    vi.resetModules()
+    vi.doMock('node-pty', () => ({ spawn: nodePtySpawn }))
+    vi.doMock('../src/linux-scope.ts', () => ({
+      launchLinuxScope: vi.fn(),
+      prepareLinuxTerminalScope,
+      probeLinuxScope,
+    }))
+    let fiber: { dispose(): Promise<void> } | undefined
+    try {
+      const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts')
+      const ctx = new Context()
+      fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime)
+      const runtime = ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>
+      runtime.internals = { platform: 'linux' }
+      runtime.terminalInspector = inspector
+
+      const handle = await runtime.spawnTerminal({
+        argv: ['shell', '--literal'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 10,
+      })
+
+      expect(probeLinuxScope).toHaveBeenCalledOnce()
+      expect(prepareLinuxTerminalScope).toHaveBeenCalledExactlyOnceWith(['shell', '--literal'])
+      expect(nodePtySpawn).toHaveBeenCalledWith(
+        '/usr/bin/systemd-run',
+        ['--user', '--scope', '--', 'shell', '--literal'],
+        expect.objectContaining({ rows: 24, cols: 80 }),
+      )
+      expect(bindOwner).toHaveBeenCalledOnce()
+      expect(launcherStates).toEqual([true])
+      expect(launcherRunning?.()).toBe(true)
+
+      exitListener?.({ exitCode: 0 })
+      expect(launcherRunning?.()).toBe(false)
+      await handle.done
+      await new Promise(resolve => setImmediate(resolve))
+      expect(owner.signal).toHaveBeenCalledExactlyOnceWith('SIGTERM')
+      expect(owner.waitForExit).toHaveBeenCalledOnce()
+    } finally {
+      await fiber?.dispose()
+      vi.doUnmock('node-pty')
+      vi.doUnmock('../src/linux-scope.ts')
+      vi.resetModules()
+    }
+  })
+
   it('retains a terminal whose automatic cleanup fails', async () => {
     let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined
     const terminal = {
@@ -456,31 +541,32 @@ describe('LocalSubprocessRuntime', () => {
   })
 
   it('reports the platform-specific reason for every fallback mode', async () => {
-    const ctx = new Context()
-    const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
-    const fiber = await ctx.plugin(LocalSubprocessRuntime)
-    const runtime = ctx.subprocess as unknown as {
-      warnFallback(platform: NodeJS.Platform): void
-    }
-    try {
-      for (const [platform, reason] of [
-        ['darwin', 'macOS has no supported persistent process-range owner'],
-        ['linux', 'a modern readable user-systemd scope is unavailable'],
-        ['win32', 'the Win32 Job runner is unavailable'],
-        ['freebsd', 'platform freebsd has no native managed range'],
-      ] as const) {
-        runtime.warnFallback(platform)
+    for (const [platform, kind, reason] of [
+      ['darwin', 'ordinary', 'macOS has no supported persistent process-range owner'],
+      ['linux', 'terminal', 'a modern readable user-systemd scope is unavailable'],
+      ['win32', 'ordinary', 'the Win32 Job runner is unavailable'],
+      ['win32', 'terminal', 'Windows ConPTY remains outside Job containment'],
+      ['freebsd', 'ordinary', 'platform freebsd has no native managed range'],
+    ] as const) {
+      const ctx = new Context()
+      const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+      const fiber = await ctx.plugin(LocalSubprocessRuntime)
+      const runtime = ctx.subprocess as unknown as {
+        warnFallback(platform: NodeJS.Platform, kind: 'ordinary' | 'terminal'): void
+      }
+      try {
+        runtime.warnFallback(platform, kind)
         expect(warning).toHaveBeenLastCalledWith(
           expect.stringContaining(reason),
         )
+      } finally {
+        warning.mockRestore()
+        await fiber.dispose()
       }
-    } finally {
-      warning.mockRestore()
-      await fiber.dispose()
     }
   })
 
-  it('selects each available native owner once and contains release-observer failures', async () => {
+  it('selects an available native owner for every eligible spawn and contains release-observer failures', async () => {
     const linuxLaunch = { kind: 'linux' }
     const windowsLaunch = { kind: 'windows' }
     const launchLinuxScope = vi.fn(() => linuxLaunch)
@@ -536,7 +622,7 @@ describe('LocalSubprocessRuntime', () => {
       await new Promise(resolve => setImmediate(resolve))
       await linuxRuntime.spawn(spec('true')).done
       await new Promise(resolve => setImmediate(resolve))
-      expect(probeLinuxScope).toHaveBeenCalledOnce()
+      expect(probeLinuxScope).toHaveBeenCalledTimes(3)
       expect(launchLinuxScope).toHaveBeenCalledTimes(2)
 
       const windowsContext = new Context()

+ 19 - 4
packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts

@@ -16,6 +16,21 @@ function spec(graceMs = 30): SubprocessSpawnSpec {
 }
 
 describe('managed process binding', () => {
+  it('does not miss an abort between the initial check and listener registration', async () => {
+    let aborted = false
+    const addEventListener = vi.fn(() => { aborted = true })
+    const removeEventListener = vi.fn()
+    const signal = {
+      get aborted() { return aborted },
+      addEventListener,
+      removeEventListener,
+    } as unknown as AbortSignal
+
+    await expect(waitWithAbort(new Promise<void>(() => {}), signal)).resolves.toBe(false)
+    expect(addEventListener).toHaveBeenCalledOnce()
+    expect(removeEventListener).toHaveBeenCalledOnce()
+  })
+
   it('contains owner failure after an already-aborted wait returns false', async () => {
     const controller = new AbortController()
     const ownerFailure = Promise.withResolvers<undefined>()
@@ -129,7 +144,7 @@ describe('managed process binding', () => {
       stdin: wrapper.stdin,
       stdout: wrapper.stdout,
       stderr: wrapper.stderr,
-      pid: wrapper.pid as number,
+      pid: wrapper.pid,
       direct: direct.promise,
       owner: { signal: vi.fn(), waitForExit: async () => {} },
     })
@@ -154,7 +169,7 @@ describe('managed process binding', () => {
       stdin: wrapper.stdin,
       stdout: wrapper.stdout,
       stderr: wrapper.stderr,
-      pid: wrapper.pid as number,
+      pid: wrapper.pid,
       direct: new Promise(() => {}),
       owner: { signal: vi.fn(), waitForExit: async () => { throw failure } },
     })
@@ -178,7 +193,7 @@ describe('managed process binding', () => {
       stdin: wrapper.stdin,
       stdout: wrapper.stdout,
       stderr: wrapper.stderr,
-      pid: wrapper.pid as number,
+      pid: wrapper.pid,
       direct,
       owner: { signal, waitForExit: async () => {} },
     })
@@ -208,7 +223,7 @@ describe('managed process binding', () => {
       stdin: wrapper.stdin,
       stdout: wrapper.stdout,
       stderr: wrapper.stderr,
-      pid: wrapper.pid as number,
+      pid: wrapper.pid,
       direct: direct.promise,
       owner: {
         signal,

+ 148 - 1
packages/subprocess/subprocess-local/tests/native-containment.spec.ts

@@ -2,7 +2,9 @@ import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterAll, describe, expect, it } from 'vitest'
-import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
+import { Context } from '@deepseek-ai/cordis'
+import type { SubprocessSpawnSpec, SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
+import LocalSubprocessRuntime from '../src/index.ts'
 import { launchLinuxScope, probeLinuxScope } from '../src/linux-scope.ts'
 import { bindManagedProcess } from '../src/spawn.ts'
 
@@ -56,6 +58,77 @@ async function waitGone(pid: number): Promise<void> {
   throw new Error(`pid ${pid} remained alive`)
 }
 
+interface LinuxProcessState {
+  parentPid: number
+  processGroupId: number
+  sessionId: number
+  ttyNumber: number
+  foregroundProcessGroupId: number
+}
+
+function readLinuxProcessState(pid: number): LinuxProcessState {
+  const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
+  const fields = stat.slice(stat.lastIndexOf(')') + 2).trim().split(/\s+/)
+  const [parentPid, processGroupId, sessionId, ttyNumber, foregroundProcessGroupId] = fields
+    .slice(1, 6)
+    .map(Number)
+  if ([parentPid, processGroupId, sessionId, ttyNumber, foregroundProcessGroupId]
+    .some(value => !Number.isSafeInteger(value))) {
+    throw new Error(`invalid /proc state for pid ${String(pid)}`)
+  }
+  return {
+    parentPid: parentPid as number,
+    processGroupId: processGroupId as number,
+    sessionId: sessionId as number,
+    ttyNumber: ttyNumber as number,
+    foregroundProcessGroupId: foregroundProcessGroupId as number,
+  }
+}
+
+async function waitReparented(pid: number, originalParentPid: number): Promise<LinuxProcessState> {
+  const deadline = Date.now() + 5_000
+  while (Date.now() < deadline) {
+    const state = readLinuxProcessState(pid)
+    if (state.parentPid !== originalParentPid) return state
+    await new Promise(resolve => setTimeout(resolve, 20))
+  }
+  throw new Error(`pid ${String(pid)} remained parented to ${String(originalParentPid)}`)
+}
+
+function captureTerminalOutput(handle: SubprocessTerminalHandle): {
+  text(): string
+  waitFor(marker: string): Promise<string>
+} {
+  let output = ''
+  handle.output.on('data', (chunk: Buffer) => { output += chunk.toString() })
+  return {
+    text: () => output,
+    waitFor: async (marker) => {
+      const deadline = Date.now() + 5_000
+      while (!output.includes(marker) && Date.now() < deadline) {
+        await new Promise(resolve => setTimeout(resolve, 20))
+      }
+      if (!output.includes(marker)) {
+        throw new Error(`terminal did not emit ${JSON.stringify(marker)}; output: ${JSON.stringify(output)}`)
+      }
+      return output
+    },
+  }
+}
+
+async function waitForInputReadiness(handle: SubprocessTerminalHandle): Promise<{
+  processGroupId: number
+  inputWaiting: boolean
+}> {
+  const deadline = Date.now() + 5_000
+  while (Date.now() < deadline) {
+    const foreground = await handle.inspectForeground()
+    if (foreground?.inputWaiting === true) return foreground
+    await new Promise(resolve => setTimeout(resolve, 20))
+  }
+  throw new Error(`terminal ${String(handle.pid)} never became input-ready`)
+}
+
 const linuxNative = process.platform === 'linux' && probeLinuxScope()
 
 describe.skipIf(!linuxNative)('Linux user-systemd native containment', () => {
@@ -82,4 +155,78 @@ describe.skipIf(!linuxNative)('Linux user-systemd native containment', () => {
     const deniedHandle = bindManagedProcess(denied, launchLinuxScope(denied))
     await expect(deniedHandle.done).rejects.toMatchObject({ code: 'EACCES' })
   })
+
+  it('keeps PTY identity and readiness while containing a reparented setsid descendant', async () => {
+    const escapedPath = join(scratch, `escaped-terminal-${Date.now()}.sh`)
+    const terminalPath = join(scratch, `terminal-${Date.now()}.sh`)
+    const launcherPidFile = join(scratch, `terminal-launcher-${Date.now()}.pid`)
+    const descendantPidFile = join(scratch, `terminal-descendant-${Date.now()}.pid`)
+    writeFileSync(escapedPath, `#!/bin/sh
+printf '%s\\n' "$$" > "$1"
+trap '' TERM
+while :; do sleep 60; done
+`, { mode: 0o700 })
+    writeFileSync(terminalPath, `#!/bin/bash
+set -eu
+launcher_pid_file=$1
+descendant_pid_file=$2
+escaped_path=$3
+sh -c 'printf "%s\\n" "$$" > "$1"; setsid "$2" "$3" </dev/null >/dev/null 2>&1 &' sh "$launcher_pid_file" "$escaped_path" "$descendant_pid_file"
+while [ ! -s "$descendant_pid_file" ]; do sleep 0.01; done
+if [ -r /dev/tty ] && [ -w /dev/tty ]; then tty_ready=yes; else tty_ready=no; fi
+printf 'PTY_READY pid=%s tty=%s\\n' "$$" "$tty_ready" > /dev/tty
+IFS= read -r value < /dev/tty
+printf 'PTY_INPUT=%s\\n' "$value" > /dev/tty
+while :; do sleep 60; done
+`, { mode: 0o700 })
+
+    const ctx = new Context()
+    const fiber = await ctx.plugin(LocalSubprocessRuntime)
+    let descendant: number | undefined
+    let handle: SubprocessTerminalHandle | undefined
+    try {
+      handle = await ctx.subprocess.spawnTerminal({
+        argv: [terminalPath, launcherPidFile, descendantPidFile, escapedPath],
+        cwd: scratch,
+        rows: 24,
+        cols: 80,
+        graceMs: 100,
+      })
+      const output = captureTerminalOutput(handle)
+      const readyOutput = await output.waitFor('PTY_READY')
+      const reportedPid = Number(/PTY_READY pid=(\d+) tty=yes/.exec(readyOutput)?.[1])
+      expect(reportedPid, readyOutput).toBe(handle.pid)
+
+      const top = readLinuxProcessState(handle.pid)
+      expect(top).toMatchObject({
+        processGroupId: handle.pid,
+        sessionId: handle.pid,
+        foregroundProcessGroupId: handle.pid,
+      })
+      expect(top.ttyNumber).not.toBe(0)
+
+      const foreground = await waitForInputReadiness(handle)
+      expect(foreground).toEqual({ processGroupId: handle.pid, inputWaiting: true })
+      await handle.write('continue\n')
+      await output.waitFor('PTY_INPUT=continue')
+
+      const launcher = await waitForPid(launcherPidFile)
+      descendant = await waitForPid(descendantPidFile)
+      const escaped = await waitReparented(descendant, launcher)
+      expect(escaped.parentPid).not.toBe(launcher)
+      expect(escaped.processGroupId).toBe(descendant)
+      expect(escaped.sessionId).toBe(descendant)
+      expect(escaped.sessionId).not.toBe(handle.pid)
+
+      await handle.terminate()
+      await handle.done
+      await waitGone(descendant)
+    } finally {
+      if (handle !== undefined) await handle.terminate().catch(() => {})
+      if (descendant !== undefined) {
+        try { process.kill(descendant, 'SIGKILL') } catch { /* already contained */ }
+      }
+      await fiber.dispose()
+    }
+  }, 15_000)
 })

+ 729 - 9
packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts

@@ -1,12 +1,14 @@
 import { spawn, spawnSync } from 'node:child_process'
 import type { ChildProcess } from 'node:child_process'
 import { EventEmitter } from 'node:events'
-import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { describe, expect, it, vi } from 'vitest'
-import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
+import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
+import { Win32Error } from '@deepseek-ai/dsh-win32-process'
+import type { NativePtr, Win32ProcessBindings } from '@deepseek-ai/dsh-win32-process'
 import {
   cleanupAfterRunner,
   runnerDirectResult,
@@ -25,12 +27,13 @@ import {
   readRunnerEventsAsync,
   serializeSpawnError,
 } from '../src/runner-protocol.ts'
+import { reportSpawnRunnerFailure, runSpawnRunner } from '../src/spawn-runner.ts'
 
 const sourceInvocation = [
   process.execPath,
   '--import',
   'tsx/esm',
-  fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/src/spawn-runner.ts')),
+  fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/src/bin.ts')),
 ]
 function spec(overrides: Partial<SubprocessSpawnSpec> = {}): SubprocessSpawnSpec {
   return {
@@ -46,6 +49,60 @@ function fakeChild(pid: number | undefined): ChildProcess {
   return { pid } as ChildProcess
 }
 
+class FakeRunnerHost extends EventEmitter {
+  env: NodeJS.ProcessEnv = {}
+  exitCode: number | undefined
+  connected = false
+  directory = process.cwd()
+  readonly disconnect = vi.fn(() => { this.connected = false })
+
+  cwd(): string { return this.directory }
+  chdir(directory: string): void { this.directory = directory }
+}
+
+function asRunnerHost(host: FakeRunnerHost): Parameters<typeof runSpawnRunner>[1] {
+  return host as unknown as Parameters<typeof runSpawnRunner>[1]
+}
+
+type RunnerInternals = NonNullable<Parameters<typeof runSpawnRunner>[2]>
+
+const fakeWin32Api = {} as Win32ProcessBindings
+const fakeProcessHandle = 60n as NativePtr
+const fakeJobHandle = 50n as NativePtr
+
+function fakeRunnerInternals(overrides: Partial<RunnerInternals> = {}): RunnerInternals {
+  let nextPipeHandle = 70n
+  return {
+    spawn,
+    loadWin32ProcessBindings: vi.fn(() => fakeWin32Api),
+    openNamedPipeForStdio: vi.fn(() => nextPipeHandle++),
+    spawnCurrentTokenJobProcess: vi.fn(() => ({
+      pid: 1234,
+      process: fakeProcessHandle,
+      job: fakeJobHandle,
+    })),
+    pollProcessExit: vi.fn(() => 0),
+    isJobEmpty: vi.fn(() => true),
+    terminateJob: vi.fn(),
+    waitForProcessExit: vi.fn(() => 0),
+    closeHandleChecked: vi.fn(),
+    ...overrides,
+  } as RunnerInternals
+}
+
+function win32RunnerArgs(
+  requestPath: string,
+  eventsPath: string,
+  pipes: string[] = [],
+): string[] {
+  return [
+    '--mode', 'win32',
+    '--request', requestPath,
+    '--events', eventsPath,
+    ...pipes,
+  ]
+}
+
 function runRunner(invocation: string[], requestPath: string, eventsPath: string) {
   const [command, ...prefix] = invocation
   return spawnSync(command as string, [
@@ -62,6 +119,14 @@ function runRunner(invocation: string[], requestPath: string, eventsPath: string
 describe('spawn runner transport', () => {
   it('selects the source runner from source-plane execution', () => {
     expect(spawnRunnerInvocation()).toEqual(sourceInvocation)
+    const manifest = JSON.parse(readFileSync(
+      fileURLToPath(new URL('../package.json', import.meta.url)),
+      'utf8',
+    )) as { exports: Record<string, { types: string; default: string }> }
+    expect(manifest.exports['./spawn-runner']).toEqual({
+      types: './lib/types/bin.d.ts',
+      default: './lib/spawn-runner.js',
+    })
   })
 
   it('does not require SharedArrayBuffer until a native handshake runs', async () => {
@@ -100,6 +165,629 @@ describe('spawn runner transport', () => {
     expect(result.status).toBe(0)
   })
 
+  it('runs the Node target lifecycle in-process through the coverable runner logic', async () => {
+    const files = createRunnerFiles({
+      argv: [process.execPath, '-e', 'process.exit(12)'],
+      cwd: process.cwd(),
+      env: {},
+    })
+    const host = new FakeRunnerHost()
+    try {
+      await runSpawnRunner([
+        '--mode', 'node',
+        '--request', files.requestPath,
+        '--events', files.eventsPath,
+      ], asRunnerHost(host))
+      expect(host.exitCode).toBe(12)
+      expect(readRunnerEvents(files.eventsPath)).toEqual([
+        expect.objectContaining({ type: 'started' }),
+        { type: 'exit', exitCode: 12, signal: null },
+      ])
+    } finally {
+      cleanupRunnerFiles(files)
+    }
+  })
+
+  it('reports an in-process Node target spawn failure', async () => {
+    const files = createRunnerFiles({
+      argv: [`missing-dsh-runner-target-${String(process.pid)}-${String(Date.now())}`],
+      cwd: process.cwd(),
+      env: {},
+    })
+    const host = new FakeRunnerHost()
+    try {
+      await runSpawnRunner([
+        '--mode', 'node',
+        '--request', files.requestPath,
+        '--events', files.eventsPath,
+      ], asRunnerHost(host))
+      expect(host.exitCode).toBe(127)
+      const [event] = readRunnerEvents(files.eventsPath)
+      expect(event?.type).toBe('spawn-error')
+      if (event?.type !== 'spawn-error') throw new Error('expected spawn error')
+      expect(event.error.code).toBe('ENOENT')
+    } finally {
+      cleanupRunnerFiles(files)
+    }
+  })
+
+  it('contains a post-start Node runner error and ignores scope signals', async () => {
+    const files = createRunnerFiles({ argv: ['node'], cwd: process.cwd(), env: {} })
+    const host = new FakeRunnerHost()
+    const child = Object.assign(new EventEmitter(), { pid: 4321 }) as ChildProcess
+    const injectedSpawn = vi.fn(() => {
+      queueMicrotask(() => {
+        host.emit('SIGTERM')
+        child.emit('spawn')
+        child.emit('error', new Error('post-start node failure'))
+        child.emit('exit', 0, null)
+      })
+      return child
+    }) as unknown as typeof spawn
+    try {
+      await runSpawnRunner([
+        '--mode', 'node',
+        '--request', files.requestPath,
+        '--events', files.eventsPath,
+      ], asRunnerHost(host), fakeRunnerInternals({ spawn: injectedSpawn }))
+      expect(host.exitCode).toBe(127)
+      expect(readRunnerEvents(files.eventsPath)).toEqual([
+        { type: 'started', pid: 4321 },
+        { type: 'runner-error', error: { name: 'Error', message: 'post-start node failure' } },
+      ])
+      expect(host.listenerCount('SIGTERM')).toBe(0)
+    } finally {
+      cleanupRunnerFiles(files)
+    }
+  })
+
+  it('maps a signal-only Node exit to the runner failure exit code', async () => {
+    const files = createRunnerFiles({ argv: ['node'], cwd: process.cwd(), env: {} })
+    const host = new FakeRunnerHost()
+    const child = Object.assign(new EventEmitter(), { pid: 4321 }) as ChildProcess
+    const injectedSpawn = vi.fn(() => {
+      queueMicrotask(() => {
+        child.emit('spawn')
+        child.emit('exit', null, 'SIGTERM')
+      })
+      return child
+    }) as unknown as typeof spawn
+    try {
+      await runSpawnRunner([
+        '--mode', 'node',
+        '--request', files.requestPath,
+        '--events', files.eventsPath,
+      ], asRunnerHost(host), fakeRunnerInternals({ spawn: injectedSpawn }))
+      expect(host.exitCode).toBe(1)
+      expect(readRunnerEvents(files.eventsPath)).toEqual([
+        { type: 'started', pid: 4321 },
+        { type: 'exit', exitCode: null, signal: 'SIGTERM' },
+      ])
+    } finally {
+      cleanupRunnerFiles(files)
+    }
+  })
+
+  it('runs the in-process capability probes and always closes the probe Job', async () => {
+    const nodeHost = new FakeRunnerHost()
+    await expect(runSpawnRunner(
+      ['--mode', 'probe-node'],
+      asRunnerHost(nodeHost),
+      fakeRunnerInternals(),
+    )).resolves.toBeUndefined()
+
+    const host = new FakeRunnerHost()
+    host.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe'
+    host.directory = 'C:\\runner'
+    const internals = fakeRunnerInternals()
+    await expect(runSpawnRunner(
+      ['--mode', 'probe-win32'],
+      asRunnerHost(host),
+      internals,
+    )).resolves.toBeUndefined()
+    expect(internals.spawnCurrentTokenJobProcess).toHaveBeenCalledWith(fakeWin32Api, {
+      command: 'C:\\Windows\\System32\\cmd.exe',
+      args: ['/d', '/s', '/c', 'exit 0'],
+      cwd: 'C:\\runner',
+    })
+    expect(internals.waitForProcessExit).toHaveBeenCalledWith(fakeWin32Api, fakeProcessHandle)
+    expect(internals.closeHandleChecked).toHaveBeenCalledWith(
+      fakeWin32Api,
+      fakeJobHandle,
+      'subprocess Windows Job probe',
+    )
+
+    const legacyHost = new FakeRunnerHost()
+    legacyHost.env.COMSPEC = 'legacy-cmd.exe'
+    const failing = fakeRunnerInternals({ waitForProcessExit: vi.fn(() => 9) })
+    await expect(runSpawnRunner(
+      ['--mode', 'probe-win32'],
+      asRunnerHost(legacyHost),
+      failing,
+    )).rejects.toThrow('probe exited with code 9')
+    expect(failing.closeHandleChecked).toHaveBeenCalledWith(
+      fakeWin32Api,
+      fakeJobHandle,
+      'subprocess Windows Job probe',
+    )
+
+    await expect(runSpawnRunner(
+      ['--mode', 'probe-win32'],
+      asRunnerHost(new FakeRunnerHost()),
+      fakeRunnerInternals(),
+    )).rejects.toThrow('without ComSpec')
+  })
+
+  it('runs the Win32 target, forwards every pipe, and waits for an empty Job', async () => {
+    vi.useFakeTimers()
+    const files = createRunnerFiles({
+      argv: ['tool.exe', 'literal $HOME'],
+      cwd: 'C:\\target',
+      env: { ONLY: 'kept' },
+    })
+    const host = new FakeRunnerHost()
+    host.env.STALE = 'removed'
+    host.directory = 'C:\\runner'
+    host.connected = true
+    const pollProcessExit = vi.fn()
+      .mockReturnValueOnce(undefined)
+      .mockReturnValueOnce(42)
+    const isJobEmpty = vi.fn()
+      .mockReturnValueOnce(false)
+      .mockReturnValueOnce(true)
+    const internals = fakeRunnerInternals({ pollProcessExit, isJobEmpty })
+    try {
+      const running = runSpawnRunner(win32RunnerArgs(files.requestPath, files.eventsPath, [
+        '--stdin-pipe', '\\\\.\\pipe\\stdin',
+        '--stdout-pipe', '\\\\.\\pipe\\stdout',
+        '--stderr-pipe', '\\\\.\\pipe\\stderr',
+      ]), asRunnerHost(host), internals)
+      await vi.advanceTimersByTimeAsync(30)
+      await running
+
+      expect(host.env).toEqual({ ONLY: 'kept' })
+      expect(host.directory).toBe('C:\\runner')
+      expect(host.disconnect).toHaveBeenCalledOnce()
+      expect(internals.openNamedPipeForStdio).toHaveBeenNthCalledWith(
+        1,
+        fakeWin32Api,
+        '\\\\.\\pipe\\stdin',
+        'read',
+      )
+      expect(internals.openNamedPipeForStdio).toHaveBeenNthCalledWith(
+        2,
+        fakeWin32Api,
+        '\\\\.\\pipe\\stdout',
+        'write',
+      )
+      expect(internals.openNamedPipeForStdio).toHaveBeenNthCalledWith(
+        3,
+        fakeWin32Api,
+        '\\\\.\\pipe\\stderr',
+        'write',
+      )
+      expect(internals.spawnCurrentTokenJobProcess).toHaveBeenCalledWith(
+        fakeWin32Api,
+        { command: 'tool.exe', args: ['literal $HOME'], cwd: 'C:\\target' },
+        {
+          stdin: 70n,
+          stdout: 71n,
+          stderr: 72n,
+        },
+      )
+      expect(pollProcessExit).toHaveBeenCalledTimes(2)
+      expect(isJobEmpty).toHaveBeenCalledTimes(2)
+      expect(readRunnerEvents(files.eventsPath)).toEqual([
+        { type: 'started', pid: 1234 },
+        { type: 'exit', exitCode: 42, signal: null },
+      ])
+      expect(internals.closeHandleChecked).toHaveBeenCalledWith(
+        fakeWin32Api,
+        fakeProcessHandle,
+        'ordinary direct process',
+      )
+      expect(internals.closeHandleChecked).toHaveBeenCalledWith(
+        fakeWin32Api,
+        fakeJobHandle,
+        'ordinary process Job',
+      )
+    } finally {
+      vi.useRealTimers()
+      cleanupRunnerFiles(files)
+    }
+  })
+
+  it('accepts only the Win32 terminate IPC message and coalesces disconnect', async () => {
+    vi.useFakeTimers()
+    const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} })
+    const host = new FakeRunnerHost()
+    const internals = fakeRunnerInternals()
+    try {
+      const running = runSpawnRunner(
+        win32RunnerArgs(files.requestPath, files.eventsPath),
+        asRunnerHost(host),
+        internals,
+      )
+      host.emit('message', null)
+      host.emit('message', 'terminate')
+      host.emit('message', { type: 'other' })
+      host.emit('message', { type: 'terminate' })
+      host.emit('message', { type: 'terminate' })
+      host.emit('disconnect')
+      await vi.advanceTimersByTimeAsync(10)
+      await running
+
+      expect(internals.terminateJob).toHaveBeenCalledOnce()
+      expect(internals.terminateJob).toHaveBeenCalledWith(fakeWin32Api, fakeJobHandle, 1)
+      expect(host.disconnect).not.toHaveBeenCalled()
+      expect(readRunnerEvents(files.eventsPath)).toEqual([
+        { type: 'started', pid: 1234 },
+        { type: 'exit', exitCode: 0, signal: null },
+      ])
+    } finally {
+      vi.useRealTimers()
+      cleanupRunnerFiles(files)
+    }
+  })
+
+  it('reports a non-Error Win32 termination failure and closes both live handles', async () => {
+    const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} })
+    const host = new FakeRunnerHost()
+    host.connected = true
+    const terminateJob = vi.fn(() => { throw 'raw termination failure' })
+    const internals = fakeRunnerInternals({ terminateJob })
+    try {
+      const running = runSpawnRunner(
+        win32RunnerArgs(files.requestPath, files.eventsPath),
+        asRunnerHost(host),
+        internals,
+      )
+      host.emit('disconnect')
+      await running
+
+      expect(host.exitCode).toBe(127)
+      expect(host.disconnect).toHaveBeenCalledOnce()
+      expect(readRunnerEvents(files.eventsPath)).toEqual([
+        { type: 'started', pid: 1234 },
+        { type: 'runner-error', error: { name: 'Error', message: 'raw termination failure' } },
+      ])
+      expect(internals.closeHandleChecked).toHaveBeenCalledWith(
+        fakeWin32Api,
+        fakeProcessHandle,
+        'ordinary direct process cleanup',
+      )
+      expect(internals.closeHandleChecked).toHaveBeenCalledWith(
+        fakeWin32Api,
+        fakeJobHandle,
+        'ordinary process Job cleanup',
+      )
+    } finally {
+      cleanupRunnerFiles(files)
+    }
+  })
+
+  it.each([
+    [2, 'ENOENT'],
+    [3, 'ENOENT'],
+    [267, 'ENOENT'],
+    [5, 'EACCES'],
+    [193, 'EFTYPE'],
+    [999, 'UNKNOWN'],
+  ] as const)('maps Win32 CreateProcess error %i to %s', async (win32Code, code) => {
+    const files = createRunnerFiles({
+      argv: ['missing.exe', 'literal argument'],
+      cwd: 'C:\\target',
+      env: {},
+    })
+    const host = new FakeRunnerHost()
+    const internals = fakeRunnerInternals({
+      spawnCurrentTokenJobProcess: vi.fn(() => {
+        throw new Win32Error('CreateProcessW', win32Code)
+      }),
+    })
+    try {
+      await runSpawnRunner(
+        win32RunnerArgs(files.requestPath, files.eventsPath),
+        asRunnerHost(host),
+        internals,
+      )
+      expect(host.exitCode).toBeUndefined()
+      const [event] = readRunnerEvents(files.eventsPath)
+      expect(event?.type).toBe('spawn-error')
+      if (event?.type !== 'spawn-error') throw new Error('expected spawn error')
+      expect(event.error).toMatchObject({
+        code,
+        syscall: 'spawn missing.exe',
+        path: 'missing.exe',
+        spawnargs: ['literal argument'],
+      })
+    } finally {
+      cleanupRunnerFiles(files)
+    }
+  })
+
+  it.each([
+    [undefined, false],
+    ['ENOENT', true],
+  ] as const)('maps a target chdir failure with code %s', async (code, hasSpawnShape) => {
+    const files = createRunnerFiles({ argv: ['tool.exe', 'arg'], cwd: 'C:\\missing', env: {} })
+    const host = new FakeRunnerHost()
+    const error = Object.assign(new Error('target cwd failed'), {
+      syscall: 'chdir',
+      ...code === undefined ? {} : { code },
+    })
+    host.chdir = vi.fn(() => { throw error })
+    try {
+      await runSpawnRunner(
+        win32RunnerArgs(files.requestPath, files.eventsPath),
+        asRunnerHost(host),
+        fakeRunnerInternals(),
+      )
+      expect(host.exitCode).toBeUndefined()
+      const [event] = readRunnerEvents(files.eventsPath)
+      expect(event?.type).toBe('spawn-error')
+      if (event?.type !== 'spawn-error') throw new Error('expected spawn error')
+      expect(typeof event.error.message).toBe('string')
+      expect('path' in event.error).toBe(hasSpawnShape)
+      if (hasSpawnShape) {
+        expect(event.error).toMatchObject({
+          code: 'ENOENT',
+          syscall: 'spawn tool.exe',
+          path: 'tool.exe',
+          spawnargs: ['arg'],
+        })
+      } else {
+        expect(event.error).toMatchObject({ message: 'target cwd failed', syscall: 'chdir' })
+      }
+    } finally {
+      cleanupRunnerFiles(files)
+    }
+  })
+
+  it.each([
+    ['a non-CreateProcess Win32 error', new Win32Error('CreateFileW', 5), 'Win32Error'],
+    ['a non-Error setup failure', 'raw pipe setup failure', 'Error'],
+  ])('reports %s as runner infrastructure failure', async (_label, failure, name) => {
+    const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} })
+    const host = new FakeRunnerHost()
+    const internals = fakeRunnerInternals({
+      openNamedPipeForStdio: vi.fn(() => { throw failure }),
+    })
+    try {
+      await runSpawnRunner(win32RunnerArgs(files.requestPath, files.eventsPath, [
+        '--stdin-pipe', '\\\\.\\pipe\\stdin',
+      ]), asRunnerHost(host), internals)
+      expect(host.exitCode).toBe(127)
+      const [event] = readRunnerEvents(files.eventsPath)
+      expect(event?.type).toBe('runner-error')
+      if (event?.type !== 'runner-error') throw new Error('expected runner error')
+      expect(event.error.name).toBe(name)
+    } finally {
+      cleanupRunnerFiles(files)
+    }
+  })
+
+  it.each([
+    ['an Error', new Error('stdio close failed')],
+    ['a non-Error value', 'raw stdio close failure'],
+  ])('reports %s from the initial stdio close and retries cleanup', async (_label, failure) => {
+    const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} })
+    let failedOnce = false
+    const closeHandleChecked = vi.fn((_api, _handle, label: string) => {
+      if (!failedOnce && label.includes('pipe')) {
+        failedOnce = true
+        throw failure
+      }
+    })
+    const internals = fakeRunnerInternals({ closeHandleChecked })
+    try {
+      await runSpawnRunner(win32RunnerArgs(files.requestPath, files.eventsPath, [
+        '--stdin-pipe', '\\\\.\\pipe\\stdin',
+      ]), asRunnerHost(new FakeRunnerHost()), internals)
+      expect(readRunnerEvents(files.eventsPath)).toEqual([
+        { type: 'started', pid: 1234 },
+        {
+          type: 'runner-error',
+          error: { name: 'Error', message: failure instanceof Error ? failure.message : failure },
+        },
+      ])
+      expect(closeHandleChecked).toHaveBeenCalledWith(
+        fakeWin32Api,
+        70n,
+        'ordinary target stdin pipe',
+      )
+      expect(closeHandleChecked).toHaveBeenCalledWith(
+        fakeWin32Api,
+        70n,
+        'ordinary target stdin pipe',
+      )
+    } finally {
+      cleanupRunnerFiles(files)
+    }
+  })
+
+  it('preserves the first stdio close failure while retaining every failed handle', async () => {
+    const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} })
+    let remainingFailures = 2
+    const closeHandleChecked = vi.fn((_api, _handle, label: string) => {
+      if (remainingFailures > 0 && label.includes('pipe')) {
+        remainingFailures -= 1
+        throw remainingFailures === 1 ? new Error('first close failure') : 'second close failure'
+      }
+    })
+    const internals = fakeRunnerInternals({ closeHandleChecked })
+    try {
+      await runSpawnRunner(win32RunnerArgs(files.requestPath, files.eventsPath, [
+        '--stdin-pipe', '\\\\.\\pipe\\stdin',
+        '--stdout-pipe', '\\\\.\\pipe\\stdout',
+      ]), asRunnerHost(new FakeRunnerHost()), internals)
+      expect(readRunnerEvents(files.eventsPath)).toContainEqual({
+        type: 'runner-error',
+        error: { name: 'Error', message: 'first close failure' },
+      })
+      expect(closeHandleChecked).toHaveBeenCalledTimes(6)
+    } finally {
+      cleanupRunnerFiles(files)
+    }
+  })
+
+  it.each([
+    ['poll', 'poll failed'],
+    ['direct close', 'direct close failed'],
+    ['Job query', 'Job query failed'],
+    ['Job close', 'Job close failed'],
+  ] as const)('reports a Win32 %s failure and cleans remaining handles', async (stage, message) => {
+    vi.useFakeTimers()
+    const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} })
+    const pollProcessExit = vi.fn(() => {
+      if (stage === 'poll') throw new Error(message)
+      return 0
+    })
+    const isJobEmpty = vi.fn(() => {
+      if (stage === 'Job query') throw new Error(message)
+      return true
+    })
+    const closeHandleChecked = vi.fn((_api, _handle, label: string) => {
+      if (stage === 'direct close' && label === 'ordinary direct process') {
+        throw new Error(message)
+      }
+      if (stage === 'Job close' && label === 'ordinary process Job') {
+        throw new Error(message)
+      }
+      if (label.endsWith('cleanup')) throw new Error('ignored cleanup failure')
+    })
+    const internals = fakeRunnerInternals({ pollProcessExit, isJobEmpty, closeHandleChecked })
+    try {
+      const running = runSpawnRunner(
+        win32RunnerArgs(files.requestPath, files.eventsPath),
+        asRunnerHost(new FakeRunnerHost()),
+        internals,
+      )
+      await vi.advanceTimersByTimeAsync(10)
+      await running
+
+      expect(readRunnerEvents(files.eventsPath)).toEqual([
+        { type: 'started', pid: 1234 },
+        ...stage === 'poll' ? [] : [{ type: 'exit' as const, exitCode: 0, signal: null }],
+        { type: 'runner-error', error: { name: 'Error', message } },
+      ])
+      expect(closeHandleChecked).toHaveBeenCalledWith(
+        fakeWin32Api,
+        fakeJobHandle,
+        expect.stringContaining('Job'),
+      )
+    } finally {
+      vi.useRealTimers()
+      cleanupRunnerFiles(files)
+    }
+  })
+
+  it('preserves the first failure when termination settles reentrantly during polling', async () => {
+    vi.useFakeTimers()
+    const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} })
+    const host = new FakeRunnerHost()
+    const terminateJob = vi.fn(() => { throw new Error('reentrant termination failed') })
+    const pollProcessExit = vi.fn(() => {
+      host.emit('disconnect')
+      return 0
+    })
+    const internals = fakeRunnerInternals({ terminateJob, pollProcessExit })
+    try {
+      const running = runSpawnRunner(
+        win32RunnerArgs(files.requestPath, files.eventsPath),
+        asRunnerHost(host),
+        internals,
+      )
+      await vi.advanceTimersByTimeAsync(10)
+      await running
+
+      expect(readRunnerEvents(files.eventsPath)).toEqual([
+        { type: 'started', pid: 1234 },
+        { type: 'exit', exitCode: 0, signal: null },
+        {
+          type: 'runner-error',
+          error: { name: 'Error', message: 'reentrant termination failed' },
+        },
+      ])
+    } finally {
+      vi.useRealTimers()
+      cleanupRunnerFiles(files)
+    }
+  })
+
+  it('reports failure while restoring cwd after a successful Win32 spawn', async () => {
+    const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} })
+    const host = new FakeRunnerHost()
+    host.directory = 'C:\\runner'
+    const chdir = vi.fn((directory: string) => {
+      if (directory === 'C:\\runner') throw new Error('cwd restore failed')
+      host.directory = directory
+    })
+    host.chdir = chdir
+    try {
+      await runSpawnRunner(
+        win32RunnerArgs(files.requestPath, files.eventsPath),
+        asRunnerHost(host),
+        fakeRunnerInternals(),
+      )
+      expect(chdir).toHaveBeenCalledTimes(2)
+      expect(host.exitCode).toBe(127)
+      expect(readRunnerEvents(files.eventsPath)).toEqual([
+        { type: 'started', pid: 1234 },
+        { type: 'runner-error', error: { name: 'Error', message: 'cwd restore failed' } },
+      ])
+    } finally {
+      cleanupRunnerFiles(files)
+    }
+  })
+
+  it('disconnects after an uncaught Win32 binding setup failure', async () => {
+    const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} })
+    const host = new FakeRunnerHost()
+    host.connected = true
+    const internals = fakeRunnerInternals({
+      loadWin32ProcessBindings: vi.fn(() => { throw new Error('binding setup failed') }),
+    })
+    try {
+      await expect(runSpawnRunner(
+        win32RunnerArgs(files.requestPath, files.eventsPath),
+        asRunnerHost(host),
+        internals,
+      )).rejects.toThrow('binding setup failed')
+      expect(host.disconnect).toHaveBeenCalledOnce()
+      expect(readRunnerEvents(files.eventsPath)).toEqual([])
+    } finally {
+      cleanupRunnerFiles(files)
+    }
+  })
+
+  it.each([
+    [['--mode'], 'missing value'],
+    [['--unknown', 'value'], 'unknown argument'],
+    [['--mode', 'unknown'], 'unknown mode'],
+    [['--mode', 'node'], 'requires request and event paths'],
+  ] as const)('rejects invalid runner arguments: %s', async (argv, message) => {
+    await expect(runSpawnRunner([...argv], asRunnerHost(new FakeRunnerHost()))).rejects.toThrow(message)
+  })
+
+  it('reports only failures whose arguments identify an event transport', () => {
+    const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
+    try {
+      reportSpawnRunnerFailure([
+        '--mode', 'node',
+        '--request', files.requestPath,
+        '--events', files.eventsPath,
+      ], new Error('runner main failed'))
+      reportSpawnRunnerFailure(['--mode', 'probe-node'], new Error('ignored probe failure'))
+      reportSpawnRunnerFailure(['--mode'], new Error('unparseable failure'))
+      expect(readRunnerEvents(files.eventsPath)).toEqual([
+        { type: 'runner-error', error: { name: 'Error', message: 'runner main failed' } },
+      ])
+    } finally {
+      cleanupRunnerFiles(files)
+    }
+  })
+
   it('maps every target stdio disposition', () => {
     expect(runnerStdio(spec())).toEqual(['ignore', 'pipe', 'pipe'])
     expect(runnerStdio(spec({
@@ -299,7 +987,7 @@ describe('spawn runner transport', () => {
         error: { name: 'Error', message: 'runner setup failed', code: 'EIO' },
       })
       const result = runnerDirectResult(fakeChild(123), runnerFailure, new Promise<void>(() => {}))
-      expect(result.pid).toBe(-1)
+      expect(result.pid).toBeUndefined()
       expect(result.failureReported).toBe(true)
       await expect(result.direct).rejects.toMatchObject({ message: 'runner setup failed', code: 'EIO' })
     } finally {
@@ -309,11 +997,11 @@ describe('spawn runner transport', () => {
     const afterStartFailure = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
     try {
       appendRunnerEvent(afterStartFailure.eventsPath, { type: 'started', pid: 456 })
+      const result = runnerDirectResult(fakeChild(123), afterStartFailure, new Promise<void>(() => {}))
       appendRunnerEvent(afterStartFailure.eventsPath, {
         type: 'runner-error',
         error: { name: 'Error', message: 'post-start runner failed', code: 'EIO' },
       })
-      const result = runnerDirectResult(fakeChild(123), afterStartFailure, new Promise<void>(() => {}))
       expect(result.pid).toBe(456)
       expect(result.failureReported).toBe(false)
       await expect(result.direct).rejects.toMatchObject({ message: 'post-start runner failed', code: 'EIO' })
@@ -334,6 +1022,38 @@ describe('spawn runner transport', () => {
 
   })
 
+  it('publishes terminal events already present in the handshake snapshot', async () => {
+    const failed = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
+    try {
+      appendRunnerEvent(failed.eventsPath, {
+        type: 'spawn-error',
+        error: { name: 'Error', message: 'target missing', code: 'ENOENT' },
+      })
+      const result = runnerDirectResult(fakeChild(123), failed, new Promise<void>(() => {}))
+      let observed: Error | undefined
+      void result.direct.catch((error: unknown) => {
+        observed = error instanceof Error ? error : new Error(String(error))
+      })
+      await Promise.resolve()
+      expect(observed).toMatchObject({ message: 'target missing', code: 'ENOENT' })
+    } finally {
+      cleanupRunnerFiles(failed)
+    }
+
+    const exited = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
+    try {
+      appendRunnerEvent(exited.eventsPath, { type: 'started', pid: 456 })
+      appendRunnerEvent(exited.eventsPath, { type: 'exit', exitCode: 23, signal: null })
+      const result = runnerDirectResult(fakeChild(123), exited, new Promise<void>(() => {}))
+      let observed: SubprocessOutcome | undefined
+      void result.direct.then((outcome) => { observed = outcome })
+      await Promise.resolve()
+      expect(observed).toEqual({ exitCode: 23, signal: null })
+    } finally {
+      cleanupRunnerFiles(exited)
+    }
+  })
+
   it('requires an event snapshot started after wrapper exit before reporting a missing result', async () => {
     const staleRead = Promise.withResolvers<Awaited<ReturnType<typeof readRunnerEventsAsync>>>()
     let readCount = 0
@@ -395,7 +1115,7 @@ describe('spawn runner transport', () => {
       })
       const lifecycle = observeChildLifecycle(child)
       const result = runnerDirectResult(child, files, lifecycle.exited)
-      expect(result.pid).toBe(-1)
+      expect(result.pid).toBeUndefined()
       expect(result.failureReported).toBe(false)
       await expect(result.direct).rejects.toThrow('runner failed to start')
       await expect(lifecycle.closed).resolves.toBeUndefined()
@@ -407,14 +1127,14 @@ describe('spawn runner transport', () => {
   it('reports runner startup failure and handshake timeout without leaking request files', async () => {
     const missingChild = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
     const missingResult = runnerDirectResult(fakeChild(undefined), missingChild, new Promise<void>(() => {}))
-    expect(missingResult.pid).toBe(-1)
+    expect(missingResult.pid).toBeUndefined()
     expect(missingResult.failureReported).toBe(false)
     await expect(missingResult.direct).rejects.toThrow('runner failed to start')
     expect(existsSync(missingChild.directory)).toBe(false)
 
     const exitedChild = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
     const exitedResult = runnerDirectResult(fakeChild(2_147_483_647), exitedChild, new Promise<void>(() => {}))
-    expect(exitedResult.pid).toBe(-1)
+    expect(exitedResult.pid).toBeUndefined()
     expect(exitedResult.failureReported).toBe(false)
     await expect(exitedResult.direct).rejects.toThrow('exited before reporting target start')
     expect(existsSync(exitedChild.directory)).toBe(false)
@@ -423,7 +1143,7 @@ describe('spawn runner transport', () => {
     const now = vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValue(10_001)
     try {
       const timedOutResult = runnerDirectResult(fakeChild(process.pid), timedOut, new Promise<void>(() => {}))
-      expect(timedOutResult.pid).toBe(-1)
+      expect(timedOutResult.pid).toBeUndefined()
       expect(timedOutResult.failureReported).toBe(false)
       await expect(timedOutResult.direct).rejects.toThrow('did not report target start')
       expect(existsSync(timedOut.directory)).toBe(false)

+ 19 - 11
packages/subprocess/subprocess-local/tests/spawn.spec.ts

@@ -252,12 +252,14 @@ describe('spawnSubprocess', () => {
       ...spec('unused', { graceMs }),
       argv: [process.execPath, '-e', childScript],
     })
+    const rootPid = running.pid
+    if (rootPid === undefined) throw new Error('test child did not publish a pid')
     const helper = await waitForPidFile(pidFile)
     const realKill: typeof process.kill = process.kill.bind(process)
     let termAt = 0
     let forceSignals = 0
     const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => {
-      if (target !== -running.pid) return realKill(target, signal)
+      if (target !== -rootPid) return realKill(target, signal)
       if (signal === 'SIGTERM') {
         termAt = Date.now()
         return realKill(target, signal)
@@ -582,9 +584,8 @@ describe('OutputCollector', () => {
 })
 
 describe('killGroup', () => {
-  it('ignores non-positive pids', () => {
-    expect(() => { killGroup(-1, 'SIGTERM') }).not.toThrow()
-    expect(() => { killGroup(0, 'SIGTERM') }).not.toThrow()
+  it('ignores an unpublished pid', () => {
+    expect(() => { killGroup(undefined, 'SIGTERM') }).not.toThrow()
   })
 
   it('swallows ESRCH for vanished groups', async () => {
@@ -774,9 +775,8 @@ describe.skipIf(process.platform === 'win32')('tree-survivor escalation (termina
 })
 
 describe('coverage seams', () => {
-  it('taskkillProcessTree ignores non-positive pids and contains a missing binary', () => {
-    expect(() => { taskkillProcessTree(-1) }).not.toThrow()
-    expect(() => { taskkillProcessTree(0) }).not.toThrow()
+  it('taskkillProcessTree ignores an unpublished pid and contains a missing binary', () => {
+    expect(() => { taskkillProcessTree(undefined) }).not.toThrow()
     // On POSIX there is no taskkill; spawnSync reports the failure in its
     // result and the function stays silent — the same containment Windows
     // relies on for an already-absent tree.
@@ -792,11 +792,13 @@ describe('coverage seams', () => {
       platform: 'linux',
       linuxProcessGroupHasLiveMembers: () => false,
     })
+    const rootPid = running.pid
+    if (rootPid === undefined) throw new Error('test child did not publish a pid')
     const realKill = process.kill.bind(process)
     const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => {
       if (typeof target === 'number' && target < 0) {
         if (signal === 0) return true
-        if (signal === 'SIGKILL') realKill(running.pid, 'SIGKILL')
+        if (signal === 'SIGKILL') realKill(rootPid, 'SIGKILL')
         return true
       }
       return realKill(target, signal)
@@ -812,6 +814,8 @@ describe('coverage seams', () => {
 
   it('treats a vanished group probe as quiescent without signalling', async () => {
     const running = spawnSubprocess(spec('sleep 60'), { platform: 'linux' })
+    const rootPid = running.pid
+    if (rootPid === undefined) throw new Error('test child did not publish a pid')
     const realKill = process.kill.bind(process)
     const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => {
       if (typeof target === 'number' && target < 0) {
@@ -822,7 +826,7 @@ describe('coverage seams', () => {
     try {
       running.terminate()
       await new Promise(resolve => setTimeout(resolve, 20))
-      realKill(running.pid, 'SIGKILL')
+      realKill(rootPid, 'SIGKILL')
       await running.done
       await expect(running.waitForExit()).resolves.toBe(true)
     } finally {
@@ -832,6 +836,8 @@ describe('coverage seams', () => {
 
   it('treats an EPERM group probe as still alive', async () => {
     const running = spawnSubprocess(spec('sleep 60'), { platform: 'linux' })
+    const rootPid = running.pid
+    if (rootPid === undefined) throw new Error('test child did not publish a pid')
     const realKill = process.kill.bind(process)
     const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => {
       if (typeof target === 'number' && target < 0 && signal === 0) {
@@ -843,7 +849,7 @@ describe('coverage seams', () => {
       await expect(running.waitForExit(AbortSignal.timeout(20))).resolves.toBe(false)
     } finally {
       killSpy.mockRestore()
-      realKill(-running.pid, 'SIGKILL')
+      realKill(-rootPid, 'SIGKILL')
       await running.done
     }
   })
@@ -1001,6 +1007,8 @@ describe('coverage seams 2', () => {
     // An inert taskkill simulates a tree that never reports exit: terminate()
     // delivers nothing, so a bounded consumer wait must come back false.
     const running = spawnSubprocess(spec('sleep 60'), { spillDir, platform: 'win32', taskkill: () => {} })
+    const rootPid = running.pid
+    if (rootPid === undefined) throw new Error('test child did not publish a pid')
     running.terminate()
     const bound = new AbortController()
     const timer = setTimeout(() => { bound.abort() }, 60)
@@ -1008,7 +1016,7 @@ describe('coverage seams 2', () => {
     clearTimeout(timer)
     // Real cleanup: the injected platform spawned without detachment, so the
     // child is a plain (group-less) POSIX process — kill it directly.
-    process.kill(running.pid, 'SIGKILL')
+    process.kill(rootPid, 'SIGKILL')
     await running.done
   })
 

+ 108 - 0
packages/subprocess/subprocess-local/tests/terminal.spec.ts

@@ -5,6 +5,7 @@ import type {
   ProcessIdentity,
   ProcessInspector,
 } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
+import type { BoundProcessOwner } from '@deepseek-ai/dsh-subprocess-local/src/managed-owner.ts'
 import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess'
 
 class FakePty {
@@ -91,6 +92,113 @@ function makeHandle(pty: FakePty, inspector: ProcessInspector, graceMs: number):
 }
 
 describe('LocalTerminalHandle', () => {
+  it('terminates a managed range with TERM when it stops within the grace period', async () => {
+    const pty = new FakePty()
+    const inspector = new FakeInspector()
+    const stopped = Promise.withResolvers<undefined>()
+    const signals: Array<'SIGTERM' | 'SIGKILL'> = []
+    const owner: BoundProcessOwner = {
+      signal(signal) {
+        signals.push(signal)
+        if (signal === 'SIGTERM') {
+          pty.emitExit(0, 15)
+          stopped.resolve(undefined)
+        }
+      },
+      waitForExit: () => stopped.promise,
+    }
+    const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, 'linux', owner)
+
+    await handle.terminate()
+
+    expect(signals).toEqual(['SIGTERM'])
+    await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
+  })
+
+  it('escalates a managed range to KILL after the TERM grace expires', async () => {
+    vi.useFakeTimers()
+    const pty = new FakePty()
+    const inspector = new FakeInspector()
+    const stopped = Promise.withResolvers<undefined>()
+    const signals: Array<'SIGTERM' | 'SIGKILL'> = []
+    const owner: BoundProcessOwner = {
+      signal(signal) {
+        signals.push(signal)
+        if (signal === 'SIGKILL') {
+          pty.emitExit(0, 9)
+          stopped.resolve(undefined)
+        }
+      },
+      waitForExit: () => stopped.promise,
+    }
+    const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, 'linux', owner)
+
+    const terminating = handle.terminate()
+    await vi.advanceTimersByTimeAsync(10)
+    await terminating
+
+    expect(signals).toEqual(['SIGTERM', 'SIGKILL'])
+  })
+
+  it('force-kills a managed range when observation rejects and preserves that failure', async () => {
+    const pty = new FakePty()
+    const failure = new Error('scope became unreadable')
+    const signals: Array<'SIGTERM' | 'SIGKILL'> = []
+    const owner: BoundProcessOwner = {
+      signal: (signal) => { signals.push(signal) },
+      waitForExit: async () => { throw failure },
+    }
+    const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10, 'linux', owner)
+
+    await expect(handle.terminate()).rejects.toBe(failure)
+    expect(signals).toEqual(['SIGTERM', 'SIGKILL'])
+  })
+
+  it('routes managed terminal host exit directly to KILL', () => {
+    const pty = new FakePty()
+    const inspector = new FakeInspector()
+    const signal = vi.fn()
+    const owner: BoundProcessOwner = { signal, waitForExit: async () => {} }
+    const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, 'linux', owner)
+
+    handle.terminateForHostExit()
+
+    expect(signal).toHaveBeenCalledExactlyOnceWith('SIGKILL')
+    expect(inspector.processes).toEqual([])
+    expect(pty.kills).toEqual([])
+  })
+
+  it('waits for the node-pty exit event after the managed range becomes empty', async () => {
+    const pty = new FakePty()
+    const owner: BoundProcessOwner = { signal: vi.fn(), waitForExit: async () => {} }
+    const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 100, 'linux', owner)
+    let settled = false
+
+    const terminating = handle.terminate().then(() => { settled = true })
+    await new Promise(resolve => setImmediate(resolve))
+    expect(settled).toBe(false)
+
+    pty.emitExit()
+    await terminating
+  })
+
+  it('rejects when a managed range stops but node-pty never publishes exit', async () => {
+    vi.useFakeTimers()
+    const pty = new FakePty()
+    const signals: Array<'SIGTERM' | 'SIGKILL'> = []
+    const owner: BoundProcessOwner = {
+      signal: (signal) => { signals.push(signal) },
+      waitForExit: async () => {},
+    }
+    const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10, 'linux', owner)
+
+    const terminating = handle.terminate()
+    const rejected = expect(terminating).rejects.toThrow('terminal cleanup failed; surviving pid: 123')
+    await vi.advanceTimersByTimeAsync(10)
+    await rejected
+    expect(signals).toEqual(['SIGTERM'])
+  })
+
   it('force-kills descendants around the shell during synchronous host exit', () => {
     const pty = new FakePty()
     const inspector = new FakeInspector()

+ 1 - 1
packages/subprocess/subprocess-local/tsdown.config.ts

@@ -4,7 +4,7 @@ export default defineConfig({
   entry: {
     index: 'lib/types/index.js',
     invariant: 'lib/types/invariant.js',
-    'spawn-runner': 'lib/types/spawn-runner.js',
+    'spawn-runner': 'lib/types/bin.js',
   },
   outDir: 'lib',
   format: ['esm'],

+ 2 - 2
packages/subprocess/subprocess/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 packages/subprocess/subprocess/README.md
-README.md: 61aec9024427a6530f4e877c2fd35cdf301af169
-README.zh.md: 4740bef7062162f444cdf7b713ed4a5c52fc02be
+README.md: 4006cb8365962f4f01b2ae4bb9bcaff093dfd2b8
+README.zh.md: 2abd58727e9e51f891206faad3636486808cdabd

+ 1 - 1
packages/subprocess/subprocess/README.md

@@ -6,7 +6,7 @@ The subprocess seam (`ctx.subprocess`) is the process half of one execution worl
 
 ## Contract
 
-- `spawn(spec)` returns a live handle synchronously. The provider owns the meaning and publication timing of `pid`, which remains `-1` while unavailable or after startup fails. `done` resolves with the spawned command's exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects for spawn or provider failures.
+- `spawn(spec)` returns a live handle synchronously. The provider owns the meaning and publication timing of `pid`; it is `undefined` until a real target PID is available and does not encode failure. `done` resolves with the spawned command's exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects for spawn or provider failures.
 - Spawn working directories and executable paths belong to the provider's execution world. `resolveExecutable(command, env?, signal?)` verifies absolute commands or resolves bare names against that world's scrubbed PATH plus explicit overrides.
 - The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the caller's config, not to a hidden subprocess-service default (the `dsh-shell` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself.
 - Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement.

+ 1 - 1
packages/subprocess/subprocess/README.zh.md

@@ -6,7 +6,7 @@
 
 ## 约定
 
-- `spawn(spec)` 同步返回活动句柄。`pid` 的含义和发布时间由 provider 拥有;尚不可用或启动失败后,其值为 `-1`。`done` 以已启动命令的退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),并在 spawn 或 provider 失败时 reject。
+- `spawn(spec)` 同步返回活动句柄。`pid` 的含义和发布时间由 provider 拥有;真实目标 PID 尚不可用时为 `undefined`,且不用于表示失败。`done` 以已启动命令的退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),并在 spawn 或 provider 失败时 reject。
 - spawn 工作目录和可执行文件路径属于提供方的执行世界。`resolveExecutable(command, env?, signal?)` 验证绝对命令,或根据该执行世界清理后的 PATH 加显式覆盖来解析裸名称。
 - spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方的配置,而不属于某个隐藏的子进程服务默认值(`dsh-shell` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。
 - stdio 按流采用 Node 风格:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。

+ 2 - 2
packages/subprocess/subprocess/src/types.ts

@@ -165,8 +165,8 @@ export interface SubprocessCollectedOutputs {
  * observe.
  */
 export interface SubprocessHandle {
-  /** Provider-published process identifier; -1 while unavailable or after startup fails. */
-  readonly pid: number
+  /** Provider-published target process identifier, or undefined until it is available. */
+  readonly pid: number | undefined
   /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */
   readonly stdin: Writable | undefined
   /** The child's raw stdout, present iff spawned with `stdout: 'pipe'`. */

+ 16 - 1
packages/subprocess/subprocess/tests/service.spec.ts

@@ -26,7 +26,7 @@ class StubSubprocessRuntime extends SubprocessRuntime {
       ? { stdout: { readFrom: () => read } }
       : {}
     return {
-      pid: spec.argv.length,
+      pid: spec.argv[0] === 'pending' ? undefined : spec.argv.length,
       stdin: undefined,
       stdout: undefined,
       stderr: undefined,
@@ -68,6 +68,21 @@ describe('SubprocessRuntime seam', () => {
     expect(outcome.exitCode).toBe(0)
   })
 
+  it('preserves an unavailable provider pid without treating it as failure', async () => {
+    const ctx = new Context()
+    await ctx.plugin(StubSubprocessRuntime)
+    const handle = ctx.subprocess.spawn({
+      argv: ['pending'],
+      cwd: '/stub',
+      stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' },
+      graceMs: 1,
+    })
+
+    expect(handle.pid).toBeUndefined()
+    await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
+    await expect(handle.waitForExit()).resolves.toBe(true)
+  })
+
   it('loading a second implementation throws (one subprocess service per context — cordis standard)', async () => {
     const ctx = new Context()
     await ctx.plugin(StubSubprocessRuntime)

+ 2 - 2
packages/subprocess/win32-process/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 packages/subprocess/win32-process/README.md
-README.md: 3859a504d2ec011d01df0a43141f96f2dc89d1c9
-README.zh.md: 877acb4f4f30a7aa387dc95a2df2efee726f36e6
+README.md: dd29d5f4a30d322b6d8a4cbb16cb3d8bc32b4ba4
+README.zh.md: fc7456c3277afcd408b074b7dda96b436c8236ea

+ 2 - 2
packages/subprocess/win32-process/README.md

@@ -10,8 +10,8 @@ Low-level Win32 process library consumed by the Windows ACL sandbox and the ordi
 - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup.
 - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime.
 - **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, and then resumes its initial thread. Target code cannot run before Job assignment; controlled assignment or resume failures terminate the suspended child or close the assigned Job before releasing every owned handle.
-- **Named-pipe stdio primitive** — `openNamedPipeForStdio()` opens a parent-owned endpoint with only the target-side read or write access required by that stream. `spawnOrdinaryJobProcess()` accepts those explicit handles, temporarily enables inheritance for target creation, and otherwise uses the runner's inherited standard handle.
-- **Ordinary Job runner primitive** — `spawnOrdinaryJobProcess()` applies the suspended-create, Job-assignment, and resume lifecycle through `CreateProcessW` and returns the original process handle plus the unnamed Job to the same runner. A zero-time process wait publishes direct exit separately, while `QueryInformationJobObject(JobObjectBasicAccountingInformation)` keeps that runner alive until `ActiveProcesses` reaches zero.
+- **Named-pipe stdio primitive** — `openNamedPipeForStdio()` opens a parent-owned endpoint with only the target-side read or write access required by that stream. `spawnCurrentTokenJobProcess()` accepts those explicit handles, temporarily enables inheritance for target creation, and otherwise uses the runner's inherited standard handle.
+- **Ordinary Job runner primitive** — `spawnCurrentTokenJobProcess()` applies the suspended-create, Job-assignment, and resume lifecycle through `CreateProcessW` and returns the original process handle plus the unnamed Job to the same runner. A zero-time process wait publishes direct exit separately, while `QueryInformationJobObject(JobObjectBasicAccountingInformation)` keeps that runner alive until `ActiveProcesses` reaches zero.
 - **Explicit settlement ownership** — `waitForProcessExit()` waits and closes a sandbox process handle; ordinary runner process polling, Job accounting, and checked Job termination/closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles.
 
 The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives.

+ 2 - 2
packages/subprocess/win32-process/README.zh.md

@@ -10,8 +10,8 @@
 - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。
 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。
 - **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,把它分配给 Job,再恢复初始线程。目标代码不会在 Job 分配前运行;受控的分配或恢复失败会终止 suspended child,或在释放全部已拥有句柄前关闭已分配的 Job。
-- **named-pipe stdio 原语** — `openNamedPipeForStdio()` 打开 parent-owned endpoint,并只申请该流 target 侧需要的 read 或 write access。`spawnOrdinaryJobProcess()` 接受这些显式 handle,在创建目标期间临时启用继承;未显式提供的流继续使用 runner 继承的标准句柄。
-- **ordinary Job runner 原语** — `spawnOrdinaryJobProcess()` 通过 `CreateProcessW` 应用 suspended-create、Job-assignment 与 resume 生命周期,并把原始 process handle 与 unnamed Job 返回给同一个 runner。process 的 zero-time wait 单独发布 direct exit,`QueryInformationJobObject(JobObjectBasicAccountingInformation)` 则让该 runner 一直存活到 `ActiveProcesses` 归零。
+- **named-pipe stdio 原语** — `openNamedPipeForStdio()` 打开 parent-owned endpoint,并只申请该流 target 侧需要的 read 或 write access。`spawnCurrentTokenJobProcess()` 接受这些显式 handle,在创建目标期间临时启用继承;未显式提供的流继续使用 runner 继承的标准句柄。
+- **ordinary Job runner 原语** — `spawnCurrentTokenJobProcess()` 通过 `CreateProcessW` 应用 suspended-create、Job-assignment 与 resume 生命周期,并把原始 process handle 与 unnamed Job 返回给同一个 runner。process 的 zero-time wait 单独发布 direct exit,`QueryInformationJobObject(JobObjectBasicAccountingInformation)` 则让该 runner 一直存活到 `ActiveProcesses` 归零。
 - **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox process handle;ordinary runner 的 process polling、Job accounting 与 checked Job termination/closure 是独立操作。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。
 
 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。

+ 2 - 2
packages/subprocess/win32-process/src/index.ts

@@ -24,14 +24,14 @@ export {
   openNamedPipeForStdio,
   pollProcessExit,
   spawnInheritedJobProcess,
-  spawnOrdinaryJobProcess,
+  spawnCurrentTokenJobProcess,
   spawnPipedProcess,
   terminateJob,
   waitForProcessExit,
 } from './process.ts'
 export type {
   ChildStdioHandles,
-  OrdinaryProcessSpawnOptions,
+  CurrentTokenProcessSpawnOptions,
   SpawnedJobProcess,
   SpawnedPipedProcess,
 } from './process.ts'

+ 5 - 5
packages/subprocess/win32-process/src/process.ts

@@ -54,7 +54,7 @@ export function buildCommandLine(program: string, args: readonly string[]): stri
 }
 
 /** Ordinary process creation inputs used by the local Win32 runner. */
-export interface OrdinaryProcessSpawnOptions {
+export interface CurrentTokenProcessSpawnOptions {
   /** Executable argv entry passed through CreateProcess. */
   command: string
   /** Arguments excluding the executable. */
@@ -71,7 +71,7 @@ export interface ChildStdioHandles {
 }
 
 /** Restricted-token process creation inputs owned by the Windows ACL sandbox. */
-export interface RestrictedProcessSpawnOptions extends OrdinaryProcessSpawnOptions {
+export interface RestrictedProcessSpawnOptions extends CurrentTokenProcessSpawnOptions {
   /** Restricted primary token supplied by sandbox policy. */
   token: NativePtr
 }
@@ -357,7 +357,7 @@ export function openNamedPipeForStdio(
 /** Shared suspended-create, Job-assignment, and resume lifecycle. */
 function spawnJobProcess(
   api: Win32ProcessBindings,
-  options: OrdinaryProcessSpawnOptions,
+  options: CurrentTokenProcessSpawnOptions,
   stdio: ChildStdioHandles,
   createName: 'CreateProcessAsUserW' | 'CreateProcessW',
   create: (startupInfo: NativePtr, processInfo: NativePtr) => number,
@@ -486,9 +486,9 @@ export function spawnInheritedJobProcess(
  * @param stdio - optional explicit handles opened for this target.
  * @returns caller-owned process and Job handles after successful resume.
  */
-export function spawnOrdinaryJobProcess(
+export function spawnCurrentTokenJobProcess(
   api: Win32ProcessBindings,
-  options: OrdinaryProcessSpawnOptions,
+  options: CurrentTokenProcessSpawnOptions,
   stdio: ChildStdioHandles = {},
 ): SpawnedJobProcess {
   const commandLine = buildCommandLine(options.command, options.args)

+ 4 - 4
packages/subprocess/win32-process/tests/ordinary-process.spec.ts

@@ -5,7 +5,7 @@ import {
   isJobEmpty,
   openNamedPipeForStdio,
   pollProcessExit,
-  spawnOrdinaryJobProcess,
+  spawnCurrentTokenJobProcess,
   terminateJob,
   Win32Error,
 } from '../src/index.ts'
@@ -83,7 +83,7 @@ describe('ordinary Job process operations', () => {
       resumeThread: vi.fn(() => { events.push('resume'); return 0 }),
       closeHandle: vi.fn((handle: NativePtr) => { events.push(`close:${handle}`); return 1 }),
     })
-    expect(spawnOrdinaryJobProcess(bindings, {
+    expect(spawnCurrentTokenJobProcess(bindings, {
       command: 'probe.exe',
       args: ['literal $VALUE', 'a b'],
       cwd: 'C:\\work',
@@ -109,7 +109,7 @@ describe('ordinary Job process operations', () => {
     const bindings = api({ createProcessW: vi.fn(() => 0) })
     let caught: unknown
     try {
-      spawnOrdinaryJobProcess(bindings, { command: 'missing.exe', args: [], cwd: 'C:\\work' })
+      spawnCurrentTokenJobProcess(bindings, { command: 'missing.exe', args: [], cwd: 'C:\\work' })
     } catch (error) {
       caught = error
     }
@@ -132,7 +132,7 @@ describe('ordinary Job process operations', () => {
         return 1
       }),
     })
-    expect(spawnOrdinaryJobProcess(bindings, {
+    expect(spawnCurrentTokenJobProcess(bindings, {
       command: 'probe.exe',
       args: [],
       cwd: 'C:\\work',

+ 1 - 1
packages/typert/generator/src/cordis-catalog.ts

@@ -826,7 +826,7 @@ function renderRuntimeApi(
     '    const next: string[] = []',
     '    for (const entry of TYPE_API) {',
     '      if (included.has(entry.name)) continue',
-    '      const pattern = new RegExp(`\\b${entry.name}\\b`)',
+    '      const pattern = new RegExp(`\\\\b${entry.name}\\\\b`)',
     '      if (!frontier.some(text => pattern.test(text))) continue',
     '      included.add(entry.name)',
     '      next.push(entry.declaration)',

+ 3 - 1
packages/typert/generator/tests/cordis-catalog.spec.ts

@@ -80,7 +80,9 @@ describe('Typert-backed Cordis catalog', () => {
         )
       }
     }
-    expect(projector.renderRuntimeApi(model)).toBe(
+    const runtimeApi = projector.renderRuntimeApi(model)
+    expect(runtimeApi).toContain('const pattern = new RegExp(`\\\\b${entry.name}\\\\b`)')
+    expect(runtimeApi).toBe(
       expected('packages/extensions/tool-cordis/src/api-catalog.ts'),
     )
   })

+ 0 - 8
vitest.config.ts

@@ -84,13 +84,6 @@ const windowsRunnerCoverageExclusions = process.platform === 'win32'
   ? ['packages/sandbox/sandbox-windows-acl/src/runner.ts']
   : []
 
-// The ordinary subprocess runner is a source/built child-process entry on
-// every platform. Its real-entry smoke tests execute it out of process, where
-// the parent Vitest coverage provider cannot instrument the module.
-const subprocessRunnerCoverageExclusions = [
-  'packages/subprocess/subprocess-local/src/spawn-runner.ts',
-]
-
 // pwsh-local's run/start/lifecycle suites self-skip without a real pwsh
 // (executor.spec.ts hasPwsh), leaving this file
 // far below per-file 100% on pwsh-less hosts; the exemption keeps those hosts
@@ -199,7 +192,6 @@ export default defineConfig({
         'packages/*/*/src/types.ts',
         'packages/*/*/src/bin.ts',
         'packages/*/*/src/worker.ts',
-        ...subprocessRunnerCoverageExclusions,
         // Dynamic Host/Client composition is covered by its focused lifecycle
         // tests and assembled application checks rather than per-file coverage.
         'packages/self-modification/*/src/**/*.{ts,tsx}',

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно