@deepseek-ai/dsh-subprocess-local 0.1.1-rc.2 → 0.1.2-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/subprocess/subprocess-local/README.md
5
- README.md: 0935bb309bd10dec7503a74708a28442223bf296
6
- README.zh.md: 2f37cbce8df0b5f454d2301b78b44961cf66ea31
5
+ README.md: 5e609bf96002c62d18684517bcd815e6f4fc0bb0
6
+ README.zh.md: 9e53c9f9444c0c19ed4ec337357a9151d8f90328
package/README.md CHANGED
@@ -1,23 +1,115 @@
1
+ ---
2
+ description: "The local host provider for the subprocess service: run managed process trees and real terminal sessions on the host machine."
3
+ kind: "package-reference"
4
+ ---
5
+
1
6
  # @deepseek-ai/dsh-subprocess-local
2
7
 
3
8
  English | [中文](README.zh.md)
4
9
 
5
- Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam. `LocalSubprocessRuntime` resolves local executables, spawns ordinary detached process trees with explicit stdio, 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)).
10
+ ## Summary
11
+
12
+ Mount `dsh-subprocess-local` in any composition that runs child processes on the host: it resolves local executables, spawns detached process trees with explicit stdio, and provides real terminal sessions through `node-pty`. It has no configuration, so every disposition, limit, terminal size, and grace arrives on the spawn request from the calling capability seam. Output collection keeps a bounded in-memory tail with optional spill files for full-stream recovery, children start from a scrubbed environment, and disposal terminates and joins every running tree.
13
+
14
+ ## Table of Contents
15
+
16
+ - [Use this package](#use-this-package)
17
+ - [Understand the implementation](#understand-the-implementation)
18
+ - [Further Exploration](#further-exploration)
19
+ - [Model Experience](#model-experience)
20
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
21
+ - [Dev Note](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## Use this package
27
+
28
+ Mount the provider beside its consumers and start processes exactly as the subprocess service specifies; this package decides only how those processes run on the host.
29
+
30
+ ### Mounting the provider
31
+
32
+ Load the provider in the same composition as its consumers. It has no config fields: every choice arrives on the spawn request, so deployment-varying decisions stay with the caller's configuration.
33
+
34
+ ```yaml
35
+ - name: '@deepseek-ai/dsh-subprocess-local'
36
+ - name: '@deepseek-ai/dsh-bash-local'
37
+ ```
38
+
39
+ ### Resolving executables
40
+
41
+ Absolute executable paths are verified; bare names resolve against the scrubbed PATH with platform-aware executable extensions (`.COM`/`.EXE`/`.BAT`/`.CMD` on Windows). Relative paths containing separators are rejected — provide an absolute path or a bare PATH name — and relative PATH entries resolve from the host process cwd.
42
+
43
+ ### Collecting output
44
+
45
+ Collect mode keeps the last `maxBytes` of a stream in memory — errors and final results cluster at the end — and, when a `spill` cap is configured, appends the complete stream to a private file under a per-process directory in the OS temp dir (a `0700` directory, `0600` random-named files). A stream larger than the spill cap discards its incomplete spill and returns only the marked truncated tail. Reads are offset-based and non-consuming, so background and batch readers coexist before and after exit.
46
+
47
+ ### Running terminal sessions
48
+
49
+ `spawnTerminal` allocates a real PTY and bridges UTF-8 text; you can inspect and signal the current foreground process group and await a `terminate()` that settles every session member the provider can still observe. On Linux, an exact input wait requires a foreground thread whose fd 0 identifies the shell's controlling terminal and whose current syscall waits on that fd. If the kernel denies the syscall probe, the provider reports no exact wait and leaves the higher PTY backend to its idle inference; process sleep state is not evidence. On Windows, SIGINT is delivered as a Ctrl-C input write, SIGTSTP and SIGHUP are unsupported, and teardown verifies the shell's termination through the process table because an externally killed shell may never fire the PTY exit notification.
50
+
51
+ ### Shutdown behavior
52
+
53
+ Normal disposal terminates every running tree and terminal and awaits their exit. During a JavaScript-observable host exit — direct `process.exit()`, default uncaught exceptions, default unhandled rejections — a synchronous finalization force-terminates everything still owned (SIGKILL to the group, `taskkill /T /F` on Windows) without creating promises or timers. Unhandled `SIGTERM`/`SIGINT`/`SIGHUP`, `SIGKILL`, fatal OOM, native crashes, and power loss need an external supervisor.
54
+
55
+ ### What can go wrong
56
+
57
+ An executable that cannot be resolved fails loud with a stable error; a spawn that never starts rejects `done`. A read past the retained tail is `lossy` and points at the spill file when one exists. A daemonized descendant that leaves the tree or terminal session can outlive cleanup — see the limitations below.
58
+
59
+ -----
6
60
 
7
- ## Behavior
61
+ <a id="understand-the-implementation"></a>
62
+ ## Understand the implementation
8
63
 
9
- - **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID <pid> /T /F`. `terminate()` — the handle's only termination verb — sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent) and is a no-op once the tree is gone; `waitForExit()` polls whole-tree liveness so consumer teardown confirms real quiescence. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive.
10
- - **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.
11
- - **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).
12
- - **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.
13
- - **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.
14
- - **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.
15
- - **Terminate-and-join disposal** — the service retains live handles so its own disposal can escalate every running tree and await its exit; quiescent and spawn-failed handles leave the live set after whole-tree or terminal-session cleanup finishes.
16
- - **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener force-terminates every ordinary tree and observable terminal session still in the same live sets. The local-only operations send POSIX SIGKILL to the managed group, run Windows `taskkill /T /F`, and synchronously signal captured/current terminal identities around the PTY root kill; they create no promise or timer, preserve the host's exit code and diagnostic, contain each target's failure, and do not claim quiescence. Normal disposal keeps the awaited graceful path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md).
64
+ <details>
65
+ <summary>Implementation internalsclick to expand</summary>
17
66
 
67
+ This section explains the design decisions behind the provider and points at the code that realizes them; the observable behavior is covered in [Use this package](#use-this-package).
68
+
69
+ ### Design concept
70
+
71
+ The provider treats the process tree as the unit of lifetime. POSIX children spawn detached (their own process group) so the whole tree is signalled by negative group id with a direct-child fallback; Windows terminates by root pid through `taskkill /T`. Signalling, escalation, and teardown guard on tree liveness rather than direct-child settlement, so a TERM-trapping helper cannot outlive the handle unnoticed.
72
+
73
+ ### Source map
74
+
75
+ | File | Role |
76
+ |---|---|
77
+ | [`src/index.ts`](src/index.ts) | Service wiring: live-handle sets, disposal, host-exit finalization, executable lookup |
78
+ | [`src/spawn.ts`](src/spawn.ts) | Process plumbing: detached spawn, tail-keep collection, spill files, escalation, tree-exit observer |
79
+ | [`src/terminal.ts`](src/terminal.ts) | `node-pty` terminal handle: foreground inspection, session cleanup, Windows teardown |
80
+ | [`src/process-inspector.ts`](src/process-inspector.ts) | POSIX process-tree and session inspection |
81
+ | [`src/windows-inspector.ts`](src/windows-inspector.ts) | Windows Toolhelp32 process-table inspection via koffi |
82
+ | [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the seam owns the contract) |
83
+
84
+ ### Main flow
85
+
86
+ A spawn builds the scrubbed child environment, starts the detached process, attaches collectors to the collected streams, and returns a handle. `done` settles at process close after a bounded pipe-drain grace, so a surviving descendant that inherited a pipe cannot hold the outcome open indefinitely; the escalation timer survives direct-child settlement so SIGKILL still reaches tree survivors. Terminal cleanup sweeps descendants by exact identity, stops the shell, re-sweeps, and verifies absence through the process table.
87
+
88
+ ### Safety invariants
89
+
90
+ Spill files are opened `0600` with `O_EXCL` and random names under a `0700` per-process directory, defeating symlink planting in shared temp dirs; a failed final close withholds the spill path. Process identities carry start times, so cleanup never follows PID reuse. Host-exit finalization creates no promises or timers, preserves the host exit code and diagnostic, contains each target's failure, and does not claim quiescence.
91
+
92
+ </details>
93
+
94
+ -----
95
+
96
+ <a id="further-exploration"></a>
97
+ ## Further Exploration
98
+
99
+ Read these pages when the provider-level contract is not enough. They move from the exhaustive type reference to the abstract contract and the decisions behind the host mechanics.
100
+
101
+ - [Subprocess subsystem](../../../docs/subsystems/subprocess.md) — spawn specs, output readers, outcomes, and the `DSH_*` environment in full.
102
+ - [dsh-subprocess](../subprocess/README.md) — the abstract contract this provider implements.
103
+ - [dsh-bash-local](../../shell/bash-local/README.md) — the largest consumer and the concrete stdio shapes it asks for.
104
+ - [Subprocess seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md) — why the process half became its own seam.
105
+ - [Synchronous subprocess exit cleanup](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md) — the host-exit finalization decision and its failure modes.
106
+
107
+ -----
108
+
109
+ <a id="model-experience"></a>
18
110
  ## Model Experience
19
111
 
20
- Indirectly, through Consumers (today the bash executor family behind `dsh-tool-bash`), which own all model-facing rendering of process output and lifecycle.
112
+ Indirectly, through consumer seams such as the bash executor family, which own all model-facing rendering of spawned process output and lifecycle.
21
113
 
22
114
  #### KV Cache effect
23
115
 
@@ -25,11 +117,24 @@ No direct invalidation; the named consumers own any request-prefix changes.
25
117
 
26
118
  ## Known Limitations and Deferred Work
27
119
 
120
+ <a id="known-limitations-and-deferred-work"></a>
121
+
122
+
123
+ These limits define when the provider is a poor fit or needs special operational care. They are current package constraints, not a general platform comparison or a task backlog.
124
+
28
125
  - **Windows tree support is best-effort** — termination routes through `taskkill /PID <pid> /T /F` with all outcomes contained (absent tree, races, missing binary), and liveness falls back to the direct-child boundary.
29
- - **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.
30
- - **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.
31
- - **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.
32
- - **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.
33
- - **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.
126
+ - **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.
127
+ - **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 PTY root; on Linux, a `setsid` child leaves both the tree and the owned terminal session; the provider adds no continuous process-table monitor.
128
+ - **In-process cleanup requires a JavaScript-observable exit** — direct `process.exit()`, default uncaught exceptions, and default unhandled rejections emit Node's synchronous `exit` event; an unhandled `SIGTERM`, `SIGINT`, or `SIGHUP`, `SIGKILL`, fatal OOM, `process.abort()`, native crashes, and power loss require an external supervisor, container init, or equivalent OS owner.
129
+ - **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently named secrets (for example `*PASSPHRASE*`) pass through, and a whitelist for over-scrubbed variables is noted future work.
130
+ - **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill directory) accumulate under the OS tmpdir until something external cleans them.
131
+
132
+ <a id="dev-note"></a>
133
+ ### Dev Note
134
+
135
+ <details>
136
+ <summary>Working context for maintainers — click to expand</summary>
137
+
138
+ None.
34
139
 
35
- The raw process handling lives in `src/spawn.ts`; `src/index.ts` is the service wiring.
140
+ </details>
package/README.zh.md CHANGED
@@ -1,35 +1,140 @@
1
+ ---
2
+ description: "面向部署方与维护者的子进程服务本地宿主提供方说明:在宿主机器上运行受管进程树与真实终端会话。"
3
+ kind: "package-reference"
4
+ ---
5
+
1
6
  # @deepseek-ai/dsh-subprocess-local
2
7
 
3
8
  [English](README.md) | 中文
4
9
 
5
- [`@deepseek-ai/dsh-subprocess`](../subprocess/README.zh.md) seam 的本地 Service Provider。`LocalSubprocessRuntime` 解析本地可执行文件,以显式 stdio spawn 普通 detached 进程树,并通过 `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))。
10
+ ## 概述
11
+
12
+ 在需要于宿主机上运行子进程的组合中挂载 `dsh-subprocess-local`:它解析本地可执行文件、以显式 stdio 运行 detached 进程树,并通过 `node-pty` 提供真实终端会话。它没有任何配置,因此每项处置方式、限制、终端尺寸与宽限期都随 spawn 请求来自调用方能力 seam。输出收集在内存中保留一段有界尾部,并可选地用 spill 文件恢复完整流;子进程从清理后的环境起步;dispose(资源释放)会终止并等待每棵仍在运行的进程树退出。
13
+
14
+ ## 目录
15
+
16
+ - [使用本包](#use-this-package)
17
+ - [理解实现](#understand-the-implementation)
18
+ - [进一步探索](#further-exploration)
19
+ - [模型体验](#model-experience)
20
+ - [已知限制与延期工作](#known-limitations-and-deferred-work)
21
+ - [开发备注](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## 使用本包
27
+
28
+ 把提供方与它的消费方挂载在同一组合中,并完全按子进程服务的规定启动进程;本包只决定这些进程在宿主机上如何运行。
29
+
30
+ ### 挂载提供方
31
+
32
+ 在与消费方相同的组合中加载本提供方。它没有任何配置字段:每项选择都随 spawn 请求到达,因此随部署变化的决策留在调用方的配置里。
33
+
34
+ ```yaml
35
+ - name: '@deepseek-ai/dsh-subprocess-local'
36
+ - name: '@deepseek-ai/dsh-bash-local'
37
+ ```
38
+
39
+ ### 解析可执行文件
40
+
41
+ 绝对可执行文件路径会被验证;裸名称根据清理后的 PATH 并以平台感知的可执行文件扩展名(Windows 上为 `.COM`/`.EXE`/`.BAT`/`.CMD`)解析。含分隔符的相对路径会被拒绝——请提供绝对路径或裸 PATH 名称——相对 PATH 条目从宿主进程 cwd 解析。
42
+
43
+ ### 收集输出
44
+
45
+ 收集模式在内存中保留一条流的最后 `maxBytes`——错误与最终结果通常聚集在末尾——并在配置了 `spill` 上限时把完整流追加到 OS 临时目录下每进程目录中的私有文件(`0700` 目录、`0600` 随机命名文件)。某条流大于 spill 上限时,会丢弃不完整的 spill,只返回带截断标记的尾部。读取基于偏移量且从不消费,因此后台读取与批量读取在退出前后都可以共存。
46
+
47
+ ### 运行终端会话
48
+
49
+ `spawnTerminal` 分配真实 PTY 并桥接 UTF-8 文本;你可以检查当前前台进程组并向其发送信号,还可以等待一次 `terminate()`,让提供方仍可观察到的每个会话成员完全停稳。在 Linux 上,精确输入等待要求前台线程的 fd 0 标识 shell 的控制终端,且线程当前的 syscall 正在等待该 fd。如果内核拒绝 syscall 探测,提供方不会报告精确等待,而由上层 PTY 后端使用空闲推断;进程睡眠状态不能作为证据。在 Windows 上,SIGINT 以 Ctrl-C 输入写入投递,SIGTSTP 与 SIGHUP 不受支持,拆卸会通过进程表验证 shell 已终止,因为被外部终止的 shell 可能永远不会触发 PTY 退出通知。
50
+
51
+ ### 关闭行为
52
+
53
+ 正常 dispose 会终止每棵仍在运行的进程树与终端并等待其退出。在 JavaScript 可观察的宿主退出期间——直接 `process.exit()`、默认未捕获异常、默认未处理 rejection——同步最终清理会强制终止所有仍归本包所有的对象(对进程组发送 SIGKILL,Windows 上运行 `taskkill /T /F`),且不创建任何 Promise 或定时器。未处理的 `SIGTERM`/`SIGINT`/`SIGHUP`、`SIGKILL`、fatal OOM、native crash 与断电则需要外部 supervisor。
54
+
55
+ ### 可能出错的地方
56
+
57
+ 无法解析的可执行文件会以稳定的错误快速失败;从未启动成功的 spawn 会让 `done` reject。越过保留尾部的读取是 `lossy` 的,并在 spill 文件存在时指向它。脱离进程树或终端会话的 daemon 化后代可能比清理更长寿——见下文限制。
58
+
59
+ -----
6
60
 
7
- ## 行为
61
+ <a id="understand-the-implementation"></a>
62
+ ## 理解实现
8
63
 
9
- - **以适合平台的方式发送信号的 detached 进程树**:POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID <pid> /T /F` 终止进程树。`terminate()`(句柄唯一的终止操作)先发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;流水线与子 shell 会随父进程一起结束),进程树消亡后为空操作;`waitForExit()` 轮询整棵进程树的存活状态,使消费方的拆卸能确认真正的完全停稳。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH;重新指定父进程并脱离该组的 daemon 仍可能存活。
10
- - **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留用于诊断的尾部。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需创建、权限为 `0700` 的每进程目录之下。
11
- - **凭据清除 + 显式合并**:以 `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)。
12
- - **基于偏移量的读取**:收集模式的读取器按完整流的字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。
13
- - **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该 seam 处被拒绝,相对 PATH 条目从宿主进程 cwd 解析。
14
- - **终端进程所有权**:`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 后端负责提示符就绪、缓冲区与面向模型的操作。
15
- - **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,使自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;完全停稳与 spawn 失败的句柄会在整棵进程树或 terminal session 清理完成后离开存活集合。
16
- - **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会强制终止同一组存活集合中仍存在的每棵普通进程树和可观察 terminal session。这些仅供本地实现使用的操作会向受管 POSIX 进程组发送 SIGKILL、在 Windows 运行 `taskkill /T /F`,并在终止 PTY root 前后同步向已捕获及当前可观察的 terminal 身份发送信号;它们不会创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待温和路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)。
64
+ <details>
65
+ <summary>实现细节——点击展开</summary>
17
66
 
67
+ 本节解释提供方背后的设计决策,并指出实现它们的代码位置;可观察行为已在[使用本包](#use-this-package)中说明。
68
+
69
+ ### 设计理念
70
+
71
+ 本提供方把进程树视为生命周期单元。POSIX 子进程以 detached 方式 spawn(拥有独立进程组),因此整棵进程树以负进程组 id 发送信号,并以直接子进程作为回退;Windows 通过 `taskkill /T` 按根 pid 终止。信号发送、升级与拆卸都以进程树存活状态为守卫,而非以直接子进程结算为准,因此拦截 TERM 的辅助进程无法在无人察觉的情况下比句柄更长寿。
72
+
73
+ ### 源码地图
74
+
75
+ | 文件 | 职责 |
76
+ |---|---|
77
+ | [`src/index.ts`](src/index.ts) | 服务接线:存活句柄集合、dispose、宿主退出最终清理、可执行文件查找 |
78
+ | [`src/spawn.ts`](src/spawn.ts) | 进程管道:detached spawn、保尾收集、spill 文件、升级、进程树退出观察器 |
79
+ | [`src/terminal.ts`](src/terminal.ts) | `node-pty` 终端句柄:前台检查、会话清理、Windows 拆卸 |
80
+ | [`src/process-inspector.ts`](src/process-inspector.ts) | POSIX 进程树与会话检查 |
81
+ | [`src/windows-inspector.ts`](src/windows-inspector.ts) | 经 koffi 的 Windows Toolhelp32 进程表检查 |
82
+ | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;约定归 seam 所有) |
83
+
84
+ ### 主流程
85
+
86
+ 一次 spawn 会构建清理后的子进程环境、启动 detached 进程、把收集器挂到收集模式的流上,然后返回句柄。`done` 在进程关闭后、经过一段有界管道排空宽限期才结算,因此继承了管道的存活后代无法无限期拖住结果;升级定时器在直接子进程结算后依然存活,使 SIGKILL 仍能到达进程树幸存者。终端清理按精确身份清扫后代、停止 shell、再次清扫,并通过进程表验证其已不存在。
87
+
88
+ ### 安全不变式
89
+
90
+ spill 文件以 `0600` 权限、`O_EXCL` 与随机名称在 `0700` 每进程目录下创建,可抵御共享临时目录中的符号链接植入;最终关闭失败时不公布 spill 路径。进程身份携带启动时间,因此清理绝不会跟随 PID 复用。宿主退出最终清理不创建 Promise 或定时器,保留宿主退出码与诊断,分别包含每个目标的失败,也不会声称已经完全停稳。
91
+
92
+ </details>
93
+
94
+ -----
95
+
96
+ <a id="further-exploration"></a>
97
+ ## 进一步探索
98
+
99
+ 当提供方级约定不够用时阅读以下页面。它们从穷尽式类型参考逐步进入抽象约定,以及宿主机制背后的决策。
100
+
101
+ - [子进程子系统](../../../docs/subsystems/subprocess.zh.md)——spawn spec、输出读取器、结果与完整的 `DSH_*` 环境。
102
+ - [dsh-subprocess](../subprocess/README.zh.md)——本提供方实现的抽象约定。
103
+ - [dsh-bash-local](../../shell/bash-local/README.zh.md)——最大的消费方及其请求的具体 stdio 形态。
104
+ - [subprocess seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md)——进程部分为何成为独立的 seam。
105
+ - [同步子进程退出清理](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)——宿主退出最终清理决策及其失败模式。
106
+
107
+ -----
108
+
109
+ <a id="model-experience"></a>
18
110
  ## 模型体验
19
111
 
20
- 通过 Consumer 间接影响(目前是 `dsh-tool-bash` 背后的 bash 执行器家族);进程输出与生命周期面向模型的全部渲染归 Consumer 所有。
112
+ 通过消费方 seam(例如 bash 执行器家族)间接影响,它们负责所 spawn 进程的输出与生命周期的全部面向模型渲染。
21
113
 
22
114
  #### KV Cache 影响
23
115
 
24
116
  不会直接导致 KV Cache 失效;请求前缀变更由上述消费方负责。
25
117
 
26
- ## 已知限制与暂缓事项
118
+ ## 已知限制与延期工作
119
+
120
+ <a id="known-limitations-and-deferred-work"></a>
121
+
122
+
123
+ 这些限制说明本提供方何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用平台对比或任务积压。
124
+
125
+ - **Windows 进程树支持仅为尽力而为**——终止经由 `taskkill /PID <pid> /T /F` 完成,所有结果都被就地吸收(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界。
126
+ - **Windows 终端信号是控制台级的**——SIGINT 以 `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 因不可用而被拒绝;不带 `/F` 的 `taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。
127
+ - **守护化的终端后代仍可能逃出可观察边界**——在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 PTY 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话;本提供方不新增持续进程表监视器。
128
+ - **进程内清理要求退出阶段仍能执行 JavaScript**——直接 `process.exit()`、默认未捕获异常和默认未处理 rejection 会发出 Node 同步 `exit` 事件;未处理的 `SIGTERM`、`SIGINT` 或 `SIGHUP`、`SIGKILL`、fatal OOM、`process.abort()`、native crash 与断电,都需要外部 supervisor、容器 init 或等价的 OS 所有者负责。
129
+ - **凭据清除依赖名称启发式规则**——只匹配 `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。
130
+ - **不会删除已完成的 spill 文件**——有界的完整输出恢复文件(以及每进程私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理。
131
+
132
+ <a id="dev-note"></a>
133
+ ### 开发备注
134
+
135
+ <details>
136
+ <summary>维护者的工作上下文——点击展开</summary>
27
137
 
28
- - **Windows 进程树支持仅为尽力而为**:终止经由 `taskkill /PID <pid> /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界。
29
- - **Windows 终端信号是控制台级的**:SIGINT 以 `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 被拒绝(不可用);不带 `/F` 的 `taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。Windows 就绪没有精确的 stdin-wait 档:prompt-marker 快路径把 shell pid 作为伪前台进程组比较,其余由静默/计时档覆盖。
30
- - **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 `node-pty` 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器。
31
- - **进程内清理要求退出阶段仍能执行 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 所有者负责。
32
- - **凭据清除依赖名称启发式规则**:只匹配 `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。
33
- - **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。
138
+ 无。
34
139
 
35
- 原始进程处理位于 `src/spawn.ts`;`src/index.ts` 负责服务接线。
140
+ </details>
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { closeSync, constants, mkdtempSync, openSync, readFileSync, readSync, readdirSync, unlinkSync, writeSync } from "node:fs";
1
+ import { closeSync, constants, mkdtempSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, statSync, unlinkSync, writeSync } from "node:fs";
2
2
  import { access, stat } from "node:fs/promises";
3
3
  import { delimiter, extname, isAbsolute, join, resolve } from "node:path";
4
4
  import * as nodePty from "node-pty";
@@ -70,19 +70,21 @@ var WindowsProcessInspector = class {
70
70
  foregroundPgid(shellPid) {
71
71
  return shellPid;
72
72
  }
73
- isStdinWaiting(_pgid) {
73
+ isStdinWaiting(_pgid, _shellPid) {
74
74
  return false;
75
75
  }
76
- processTree(rootPid) {
77
- return windowsProcessTree(this.internals.snapshot(), rootPid, (pid) => this.internals.processState(pid)?.started);
78
- }
79
- processSession(_sessionId) {
80
- return [];
81
- }
82
76
  isAlive(identity) {
83
77
  const state = this.internals.processState(identity.pid);
84
78
  return state?.active === true && state.started === identity.started;
85
79
  }
80
+ snapshot() {
81
+ let entries;
82
+ return {
83
+ tree: (rootPid) => windowsProcessTree(entries ??= this.internals.snapshot(), rootPid, (pid) => this.internals.processState(pid)?.started),
84
+ session: () => [],
85
+ alive: (identity) => this.isAlive(identity)
86
+ };
87
+ }
86
88
  signalGroup(pgid, signal) {
87
89
  this.internals.taskkill(pgid, signal === "SIGKILL");
88
90
  }
@@ -264,6 +266,8 @@ function defaultWindowsProcessInternals() {
264
266
  const DEFAULT_INTERNALS = {
265
267
  readFile: (path) => readFileSync(path, "utf8"),
266
268
  readDir: (path) => readdirSync(path),
269
+ readLink: (path) => readlinkSync(path, "utf8"),
270
+ stat: (path) => statSync(path),
267
271
  open: (path) => openSync(path, "r"),
268
272
  read: (fd, buffer, length, position) => readSync(fd, buffer, 0, length, position),
269
273
  close: closeSync,
@@ -285,6 +289,7 @@ function parseProcStat(text) {
285
289
  const parentPid = Number(rest[1]);
286
290
  const pgrp = Number(rest[2]);
287
291
  const session = Number(rest[3]);
292
+ const ttyDevice = Number(rest[4]);
288
293
  const tpgid = Number(rest[5]);
289
294
  const started = rest[19];
290
295
  if (![
@@ -292,6 +297,7 @@ function parseProcStat(text) {
292
297
  parentPid,
293
298
  pgrp,
294
299
  session,
300
+ ttyDevice,
295
301
  tpgid
296
302
  ].every(Number.isSafeInteger) || state.length !== 1 || started === void 0) return void 0;
297
303
  return {
@@ -300,6 +306,7 @@ function parseProcStat(text) {
300
306
  pgrp,
301
307
  session,
302
308
  state,
309
+ ttyDevice,
303
310
  tpgid,
304
311
  started
305
312
  };
@@ -311,6 +318,21 @@ function readLinuxStat(internals, pid) {
311
318
  return;
312
319
  }
313
320
  }
321
+ function linuxDeviceNumber(value) {
322
+ return value >>> 0;
323
+ }
324
+ function readLinuxTerminalDevice(internals, pid, ttyDevice, tid) {
325
+ const terminalDevice = linuxDeviceNumber(ttyDevice);
326
+ if (terminalDevice === 0) return void 0;
327
+ const path = tid === void 0 ? `/proc/${pid}/fd/0` : `/proc/${pid}/task/${tid}/fd/0`;
328
+ try {
329
+ if (internals.readLink(path) === "/dev/tty") return terminalDevice;
330
+ const status = internals.stat(path);
331
+ return status.isCharacterDevice() && linuxDeviceNumber(status.rdev) === terminalDevice ? terminalDevice : void 0;
332
+ } catch (_unreadableStdinDevice) {
333
+ return;
334
+ }
335
+ }
314
336
  /**
315
337
  * Report whether a Linux process group has an executing member. `false`
316
338
  * means the group contains only zombie/dead entries; `undefined` means the
@@ -382,9 +404,9 @@ function pollHasStdin(internals, pid, address, count) {
382
404
  for (let offset = 0; offset + 8 <= memory.length; offset += 8) if (memory.readInt32LE(offset) === 0 && (memory.readInt16LE(offset + 4) & 1) !== 0) return true;
383
405
  return false;
384
406
  }
385
- function epollHasStdin(internals, pid, epfd) {
407
+ function epollHasStdin(internals, pid, tid, epfd) {
386
408
  try {
387
- return internals.readFile(`/proc/${pid}/fdinfo/${epfd}`).split("\n").some((line) => /^tfd:\s+0\b/.test(line.trim()));
409
+ return internals.readFile(`/proc/${pid}/task/${tid}/fdinfo/${epfd}`).split("\n").some((line) => /^tfd:\s+0\b/.test(line.trim()));
388
410
  } catch (_unreadableFdInfo) {
389
411
  return false;
390
412
  }
@@ -406,12 +428,20 @@ const SYSCALLS = {
406
428
  epollPwait: 22
407
429
  }
408
430
  };
409
- function syscallWaitsOnStdin(internals, pid, syscall, table) {
431
+ const SUPPORTED_SYSCALL_TABLES = Object.values(SYSCALLS);
432
+ function linuxSyscallTables(arch) {
433
+ const primary = SYSCALLS[arch];
434
+ if (primary === void 0) return void 0;
435
+ return [primary, ...SUPPORTED_SYSCALL_TABLES.filter((table) => table !== primary)];
436
+ }
437
+ function syscallWaitsOnStdin(internals, pid, tid, syscall, tables) {
410
438
  const [a0 = 0, a1 = 0, a2 = 0] = syscall.args;
411
- if (syscall.number === table.read) return a0 === 0;
412
- if (syscall.number === table.select || syscall.number === table.pselect) return a0 >= 1 && fdSetHasStdin(internals, pid, a1);
413
- if (syscall.number === table.poll || syscall.number === table.ppoll) return a1 >= 1 && pollHasStdin(internals, pid, a0, a1);
414
- if (syscall.number === table.epollWait || syscall.number === table.epollPwait) return a2 >= 1 && epollHasStdin(internals, pid, a0);
439
+ for (const table of tables) {
440
+ if (syscall.number === table.read) return a0 === 0;
441
+ if (syscall.number === table.select || syscall.number === table.pselect) return a0 >= 1 && fdSetHasStdin(internals, pid, a1);
442
+ if (syscall.number === table.poll || syscall.number === table.ppoll) return a1 >= 1 && pollHasStdin(internals, pid, a0, a1);
443
+ if (syscall.number === table.epollWait || syscall.number === table.epollPwait) return a2 >= 1 && epollHasStdin(internals, pid, tid, a0);
444
+ }
415
445
  return false;
416
446
  }
417
447
  var PosixProcessInspector = class {
@@ -426,6 +456,30 @@ var PosixProcessInspector = class {
426
456
  if (this.isAlive(identity)) this.internals.kill(identity.pid, signal);
427
457
  }
428
458
  };
459
+ function quiescent(state) {
460
+ return state !== void 0 && /^[ZXx]$/.test(state);
461
+ }
462
+ var PosixProcessSnapshot = class {
463
+ rows;
464
+ byPid;
465
+ constructor(rows) {
466
+ this.rows = rows;
467
+ this.byPid = new Map(rows.map((row) => [row.pid, row]));
468
+ }
469
+ tree(rootPid) {
470
+ return processTree(this.rows, rootPid);
471
+ }
472
+ session(sessionId) {
473
+ return this.rows.flatMap((row) => row.session === sessionId ? [{
474
+ pid: row.pid,
475
+ started: row.started
476
+ }] : []);
477
+ }
478
+ alive(identity) {
479
+ const row = this.byPid.get(identity.pid);
480
+ return row?.started === identity.started && !quiescent(row.state);
481
+ }
482
+ };
429
483
  function processTree(entries, rootPid) {
430
484
  const root = new Map(entries.map((entry) => [entry.pid, entry])).get(rootPid);
431
485
  if (root === void 0) return [];
@@ -459,40 +513,38 @@ var LinuxProcessInspector = class extends PosixProcessInspector {
459
513
  const tpgid = readLinuxStat(this.internals, shellPid)?.tpgid;
460
514
  return tpgid !== void 0 && tpgid > 0 ? tpgid : void 0;
461
515
  }
462
- isStdinWaiting(pgid) {
463
- const table = SYSCALLS[this.arch];
464
- if (table === void 0) return false;
516
+ isStdinWaiting(pgid, shellPid) {
517
+ const tables = linuxSyscallTables(this.arch);
518
+ if (tables === void 0) return false;
519
+ const shell = readLinuxStat(this.internals, shellPid);
520
+ if (shell === void 0) return false;
521
+ const terminalDevice = readLinuxTerminalDevice(this.internals, shellPid, shell.ttyDevice);
522
+ if (terminalDevice === void 0) return false;
465
523
  for (const pid of numericEntries(this.internals, "/proc")) {
466
- if (readLinuxStat(this.internals, pid)?.pgrp !== pgid) continue;
524
+ const process = readLinuxStat(this.internals, pid);
525
+ if (process?.pgrp !== pgid) continue;
467
526
  for (const tid of numericEntries(this.internals, `/proc/${pid}/task`)) {
468
527
  const syscall = readSyscall(this.internals, pid, tid);
469
- if (syscall !== void 0 && syscallWaitsOnStdin(this.internals, pid, syscall, table)) return true;
528
+ if (syscall !== void 0 && syscallWaitsOnStdin(this.internals, pid, tid, syscall, tables) && readLinuxTerminalDevice(this.internals, pid, process.ttyDevice, tid) === terminalDevice) return true;
470
529
  }
471
530
  }
472
531
  return false;
473
532
  }
474
- processTree(rootPid) {
475
- return processTree(numericEntries(this.internals, "/proc").flatMap((pid) => {
533
+ isAlive(identity) {
534
+ const stat = readLinuxStat(this.internals, identity.pid);
535
+ return stat?.started === identity.started && !quiescent(stat.state);
536
+ }
537
+ snapshot() {
538
+ return new PosixProcessSnapshot(numericEntries(this.internals, "/proc").flatMap((pid) => {
476
539
  const stat = readLinuxStat(this.internals, pid);
477
540
  return stat === void 0 ? [] : [{
478
541
  pid,
479
542
  parentPid: stat.parentPid,
480
- started: stat.started
543
+ started: stat.started,
544
+ session: stat.session,
545
+ state: stat.state
481
546
  }];
482
- }), rootPid);
483
- }
484
- processSession(sessionId) {
485
- return numericEntries(this.internals, "/proc").flatMap((pid) => {
486
- const stat = readLinuxStat(this.internals, pid);
487
- return stat?.session === sessionId ? [{
488
- pid,
489
- started: stat.started
490
- }] : [];
491
- });
492
- }
493
- isAlive(identity) {
494
- const stat = readLinuxStat(this.internals, identity.pid);
495
- return stat?.started === identity.started && !/^[ZXx]$/.test(stat.state);
547
+ }));
496
548
  }
497
549
  };
498
550
  function macProcessTable(internals) {
@@ -502,7 +554,9 @@ function macProcessTable(internals) {
502
554
  return [{
503
555
  pid: Number(match[1]),
504
556
  parentPid: Number(match[2]),
505
- started: match[3]
557
+ started: match[3],
558
+ session: void 0,
559
+ state: void 0
506
560
  }];
507
561
  });
508
562
  }
@@ -520,18 +574,15 @@ var MacProcessInspector = class extends PosixProcessInspector {
520
574
  return;
521
575
  }
522
576
  }
523
- isStdinWaiting(_pgid) {
577
+ isStdinWaiting(_pgid, _shellPid) {
524
578
  return false;
525
579
  }
526
- processTree(rootPid) {
527
- return processTree(macProcessTable(this.internals), rootPid);
528
- }
529
- processSession(_sessionId) {
530
- return [];
531
- }
532
580
  isAlive(identity) {
533
581
  return macProcessTable(this.internals).some((entry) => entry.pid === identity.pid && entry.started === identity.started);
534
582
  }
583
+ snapshot() {
584
+ return new PosixProcessSnapshot(macProcessTable(this.internals));
585
+ }
535
586
  };
536
587
  /**
537
588
  * Create the supported platform inspector or fail at plugin load.
@@ -997,7 +1048,7 @@ var LocalTerminalHandle = class {
997
1048
  this.graceMs = graceMs;
998
1049
  this.platform = platform;
999
1050
  this.pid = terminal.pid;
1000
- this.rootIdentity = inspector.processTree(this.pid).find((member) => member.pid === this.pid);
1051
+ this.rootIdentity = inspector.snapshot().tree(this.pid).find((member) => member.pid === this.pid);
1001
1052
  this.done = this.outcome.promise;
1002
1053
  this.dataDisposable = terminal.onData((data) => {
1003
1054
  this.output.write(Buffer$1.from(data, "utf8"));
@@ -1017,12 +1068,12 @@ var LocalTerminalHandle = class {
1017
1068
  this.terminal.write(data);
1018
1069
  }
1019
1070
  async inspectForeground() {
1020
- this.descendants();
1071
+ this.descendants(this.inspector.snapshot());
1021
1072
  const processGroupId = this.inspector.foregroundPgid(this.pid);
1022
1073
  if (processGroupId === void 0) return void 0;
1023
1074
  return {
1024
1075
  processGroupId,
1025
- inputWaiting: this.inspector.isStdinWaiting(processGroupId)
1076
+ inputWaiting: this.inspector.isStdinWaiting(processGroupId, this.pid)
1026
1077
  };
1027
1078
  }
1028
1079
  async signalForeground(signal) {
@@ -1069,22 +1120,23 @@ var LocalTerminalHandle = class {
1069
1120
  this.terminal.kill("SIGKILL");
1070
1121
  } catch (_unidentifiedShellExitedDuringHostExit) {}
1071
1122
  }
1072
- survivors(members) {
1073
- return members.filter((member) => this.inspector.isAlive(member));
1123
+ survivors(members, observed) {
1124
+ return members.filter((member) => observed.alive(member));
1074
1125
  }
1075
- descendants() {
1076
- const tree = this.inspector.processTree(this.pid);
1126
+ descendants(observed) {
1127
+ const tree = observed.tree(this.pid);
1077
1128
  const root = tree.find((member) => member.pid === this.pid);
1078
1129
  const rootVerified = this.rootIdentity !== void 0 && root !== void 0 && root.started === this.rootIdentity.started;
1079
- this.trackedDescendants = this.survivors(this.unionMembers(this.trackedDescendants, ...rootVerified ? [tree, this.inspector.processSession(this.pid)] : []).filter((member) => member.pid !== this.pid));
1130
+ this.trackedDescendants = this.survivors(this.unionMembers(this.trackedDescendants, ...rootVerified ? [tree, observed.session(this.pid)] : []).filter((member) => member.pid !== this.pid), observed);
1080
1131
  return this.trackedDescendants;
1081
1132
  }
1082
1133
  async waitForMembers(members) {
1134
+ if (members.length === 0) return [];
1083
1135
  const until = Date.now() + this.graceMs;
1084
- let survivors = this.survivors(members);
1136
+ let survivors = this.survivors(members, this.inspector.snapshot());
1085
1137
  while (survivors.length > 0 && Date.now() < until) {
1086
1138
  await delay(Math.min(25, Math.max(1, until - Date.now())));
1087
- survivors = this.survivors(members);
1139
+ survivors = this.survivors(members, this.inspector.snapshot());
1088
1140
  }
1089
1141
  return survivors;
1090
1142
  }
@@ -1096,7 +1148,7 @@ var LocalTerminalHandle = class {
1096
1148
  forceStopDescendants() {
1097
1149
  let members = this.trackedDescendants;
1098
1150
  try {
1099
- members = this.descendants();
1151
+ members = this.descendants(this.inspector.snapshot());
1100
1152
  } catch (_processTableUnavailableDuringHostExit) {}
1101
1153
  this.signalMembers(members, "SIGKILL");
1102
1154
  }
@@ -1112,13 +1164,14 @@ var LocalTerminalHandle = class {
1112
1164
  return members;
1113
1165
  }
1114
1166
  async stopDescendants() {
1115
- const captured = this.descendants();
1167
+ const captured = this.descendants(this.inspector.snapshot());
1116
1168
  this.signalMembers(captured, "SIGTERM");
1117
1169
  const capturedSurvivors = await this.waitForMembers(captured);
1118
- const members = this.unionMembers(capturedSurvivors, this.descendants());
1170
+ const members = this.unionMembers(capturedSurvivors, this.descendants(this.inspector.snapshot()));
1119
1171
  this.signalMembers(members, "SIGKILL");
1120
1172
  const survivors = await this.waitForMembers(members);
1121
- return this.survivors(this.unionMembers(survivors, this.descendants()));
1173
+ const observed = this.inspector.snapshot();
1174
+ return this.survivors(this.unionMembers(survivors, this.descendants(observed)), observed);
1122
1175
  }
1123
1176
  async stopShell() {
1124
1177
  if (this.platform === "win32") {
@@ -5,23 +5,93 @@ export interface ProcessIdentity {
5
5
  pid: number;
6
6
  started: string;
7
7
  }
8
+ interface FileStatus {
9
+ readonly rdev: number;
10
+ isCharacterDevice(): boolean;
11
+ }
12
+ /**
13
+ * One observation of the platform process table, shared by every question a
14
+ * single readiness poll or teardown pass asks.
15
+ *
16
+ * The table is read at most once, on the first question that needs it — a
17
+ * `/bin/ps` fork on macOS, a `/proc` walk on Linux, a Toolhelp32 enumeration on
18
+ * Windows. Later questions never re-read it, which is what keeps a poll's cost
19
+ * independent of how many descendants the running command spawned. Windows
20
+ * liveness needs no table at all: wait state is a per-handle question there, so
21
+ * a snapshot asked only for liveness never enumerates.
22
+ *
23
+ * A snapshot answers what the process table showed, which is what batch
24
+ * filtering wants and what signalling must not use: {@link ProcessInspector.isAlive}
25
+ * is the fence a signal takes, because it reads current state instead.
26
+ */
27
+ export interface ProcessSnapshot {
28
+ /**
29
+ * Return the root and its transitive descendants as observed, children first.
30
+ * @param rootPid - tree root to descend from.
31
+ * @returns Observed root and descendants, children before parents.
32
+ */
33
+ tree(rootPid: number): ProcessIdentity[];
34
+ /**
35
+ * Return observed members of one POSIX process session.
36
+ * @param sessionId - POSIX session identifier.
37
+ * @returns Observed session members, empty where the platform's table omits session ids.
38
+ */
39
+ session(sessionId: number): ProcessIdentity[];
40
+ /**
41
+ * Return whether the exact identity was a non-quiescent process.
42
+ * @param identity - PID plus start identity to match.
43
+ * @returns Whether that exact identity — not merely that PID — was running.
44
+ */
45
+ alive(identity: ProcessIdentity): boolean;
46
+ }
8
47
  /** Injectable OS process operations used by one local PTY session. */
9
48
  export interface ProcessInspector {
10
49
  foregroundPgid(shellPid: number): number | undefined;
11
- isStdinWaiting(pgid: number): boolean;
12
- /** Return the root and its current transitive descendants, children first. */
13
- processTree(rootPid: number): ProcessIdentity[];
14
- /** Return current members of one POSIX process session when the platform exposes them. */
15
- processSession(sessionId: number): ProcessIdentity[];
16
- /** Return whether the exact identity remains a non-quiescent process. */
50
+ /**
51
+ * Report whether the foreground group waits on the terminal shell's stdin.
52
+ *
53
+ * @param pgid Foreground process-group identifier.
54
+ * @param shellPid Persistent terminal shell process identifier.
55
+ * @returns Whether a group member is blocked reading the shell's terminal input.
56
+ */
57
+ isStdinWaiting(pgid: number, shellPid: number): boolean;
58
+ /**
59
+ * Read the process table once and answer tree, session, and liveness from it.
60
+ * @returns A process-table observation whose reads are shared.
61
+ */
62
+ snapshot(): ProcessSnapshot;
63
+ /**
64
+ * Return whether the exact identity is a non-quiescent process right now.
65
+ *
66
+ * Reads the narrowest per-identity source the platform offers rather than a
67
+ * whole table, so a signalling round can re-check every target without
68
+ * paying for a scan. Callers filtering many members at once want
69
+ * {@link ProcessSnapshot.alive} instead.
70
+ *
71
+ * @param identity - PID plus start identity to match.
72
+ * @returns Whether that exact identity — not merely that PID — is running.
73
+ */
17
74
  isAlive(identity: ProcessIdentity): boolean;
18
75
  signalGroup(pgid: number, signal: SubprocessTerminalSignal): void;
76
+ /**
77
+ * Signal one exact process identity, fenced against PID reuse.
78
+ *
79
+ * The fence reads current state immediately before the signal. An observation
80
+ * taken earlier in the same round cannot stand in for it: the observation
81
+ * preserves the original PID-to-start-time pairing, so a recycled PID would
82
+ * still match and take a signal meant for the process that exited.
83
+ *
84
+ * @param identity - PID plus start identity to signal.
85
+ * @param signal - termination signal to deliver.
86
+ */
19
87
  signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void;
20
88
  }
21
89
  /** Testable boundary around filesystem, process-table, and signal syscalls. */
22
90
  export interface ProcessInspectorInternals {
23
91
  readFile(path: string): string;
24
92
  readDir(path: string): string[];
93
+ readLink(path: string): string;
94
+ stat(path: string): FileStatus;
25
95
  open(path: string): number;
26
96
  read(fd: number, buffer: Buffer, length: number, position: number): number;
27
97
  close(fd: number): void;
@@ -34,6 +104,7 @@ interface ProcStat {
34
104
  pgrp: number;
35
105
  session: number;
36
106
  state: string;
107
+ ttyDevice: number;
37
108
  tpgid: number;
38
109
  started: string;
39
110
  }
@@ -9,7 +9,7 @@
9
9
  * @module dsh-subprocess-local/windows-inspector
10
10
  */
11
11
  import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess';
12
- import type { ProcessIdentity, ProcessInspector } from './process-inspector.ts';
12
+ import type { ProcessIdentity, ProcessInspector, ProcessSnapshot } from './process-inspector.ts';
13
13
  /** One Toolhelp32 process-table row. */
14
14
  export interface ProcessEntry {
15
15
  pid: number;
@@ -52,10 +52,9 @@ export declare class WindowsProcessInspector implements ProcessInspector {
52
52
  private readonly internals;
53
53
  constructor(internals?: WindowsProcessInspectorInternals);
54
54
  foregroundPgid(shellPid: number): number;
55
- isStdinWaiting(_pgid: number): boolean;
56
- processTree(rootPid: number): ProcessIdentity[];
57
- processSession(_sessionId: number): ProcessIdentity[];
55
+ isStdinWaiting(_pgid: number, _shellPid: number): boolean;
58
56
  isAlive(identity: ProcessIdentity): boolean;
57
+ snapshot(): ProcessSnapshot;
59
58
  signalGroup(pgid: number, signal: SubprocessTerminalSignal): void;
60
59
  signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void;
61
60
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-subprocess-local",
3
3
  "description": "Local-subprocess implementation of the DeepSeek Harness subprocess seam",
4
- "version": "0.1.1-rc.2",
4
+ "version": "0.1.2-alpha.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -33,21 +33,21 @@
33
33
  ],
34
34
  "license": "MIT",
35
35
  "peerDependencies": {
36
- "@deepseek-ai/dsh-subprocess": "^0.1.1-rc.2",
37
- "@deepseek-ai/dsh-timeout": "^0.1.1-rc.2",
38
- "@deepseek-ai/cordis": "^4.0.1",
39
- "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2"
36
+ "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.2",
37
+ "@deepseek-ai/dsh-subprocess": "^0.1.2-alpha.2",
38
+ "@deepseek-ai/cordis": "^4.0.2",
39
+ "@deepseek-ai/dsh-timeout": "^0.1.2-alpha.2"
40
40
  },
41
41
  "dependencies": {
42
42
  "koffi": "^3.1.0",
43
43
  "node-pty": "1.2.0-beta.15"
44
44
  },
45
45
  "devDependencies": {
46
- "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
47
- "@deepseek-ai/dsh-loader-smoke": "^0.1.1-rc.2",
48
- "@deepseek-ai/dsh-subprocess": "^0.1.1-rc.2",
49
- "@deepseek-ai/dsh-timeout": "^0.1.1-rc.2",
50
- "@deepseek-ai/cordis": "^4.0.1"
46
+ "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.2",
47
+ "@deepseek-ai/dsh-loader-smoke": "^0.1.2-alpha.2",
48
+ "@deepseek-ai/dsh-subprocess": "^0.1.2-alpha.2",
49
+ "@deepseek-ai/dsh-timeout": "^0.1.2-alpha.2",
50
+ "@deepseek-ai/cordis": "^4.0.2"
51
51
  },
52
52
  "scripts": {
53
53
  "postinstall": "node scripts/ensure-spawn-helper.mjs"