@deepseek-ai/dsh-subprocess-local 0.1.0-rc.7 → 0.1.0-rc.8

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: a88eab6776c7259bb8ee8fdad2d6002d8f57e133
6
- README.zh.md: ce088557875daceb5643f13bef78d6ea9a61e12f
5
+ README.md: 0935bb309bd10dec7503a74708a28442223bf296
6
+ README.zh.md: e2e6c67e4dbe1890bcb5532594a62b650bfed85d
package/README.md CHANGED
@@ -11,7 +11,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA
11
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
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
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. The higher PTY backend owns prompt readiness, buffers, and model-facing operations.
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
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
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).
17
17
 
@@ -26,7 +26,7 @@ No direct invalidation; the named consumers own any request-prefix changes.
26
26
  ## Known Limitations and Deferred Work
27
27
 
28
28
  - **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
- - **Terminal process inspection is Linux/macOS only** — the terminal primitive fails when its inspector has no supported platform implementation; Linux exact probes cover x64 and arm64, while macOS uses `ps` snapshots.
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
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
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
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.
package/README.zh.md CHANGED
@@ -11,7 +11,7 @@
11
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.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
12
12
  - **基于偏移量的读取**:收集模式的读取器按完整流的字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。
13
13
  - **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该 seam 处被拒绝,相对 PATH 条目从宿主进程 cwd 解析。
14
- - **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。
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
15
  - **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,使自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;完全停稳与 spawn 失败的句柄会在整棵进程树或 terminal session 清理完成后离开存活集合。
16
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.md)。
17
17
 
@@ -26,7 +26,7 @@
26
26
  ## 已知限制与暂缓事项
27
27
 
28
28
  - **Windows 进程树支持仅为尽力而为**:终止经由 `taskkill /PID <pid> /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界。
29
- - **终端进程检查仅支持 Linux/macOS**:检查器没有受支持的平台实现时,终端原语会失败;Linux 精确探针覆盖 x64arm64,macOS 则使用 `ps` 快照。
29
+ - **Windows 终端信号是控制台级的**:SIGINT `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP SIGHUP 被拒绝(不可用);不带 `/F``taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。Windows 就绪没有精确的 stdin-wait 档:prompt-marker 快路径把 shell pid 作为伪前台进程组比较,其余由静默/计时档覆盖。
30
30
  - **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 `node-pty` 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器。
31
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
32
  - **凭据清除依赖名称启发式规则**:只匹配 `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。
package/lib/index.js CHANGED
@@ -8,8 +8,256 @@ import { randomBytes } from "node:crypto";
8
8
  import { constants as constants$1, tmpdir } from "node:os";
9
9
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
10
10
  import { MAX_TIMER_DELAY_MS } from "@deepseek-ai/dsh-timeout";
11
+ import koffi from "koffi";
11
12
  import { Buffer as Buffer$1 } from "node:buffer";
12
13
  import { PassThrough } from "node:stream";
14
+ //#region lib/types/windows-inspector.js
15
+ /**
16
+ * Windows process-table operations for terminal readiness, signalling, and
17
+ * teardown: Toolhelp32 snapshot enumeration with GetProcessTimes creation-time
18
+ * identity and process-handle wait-state liveness, the shell pid as a pseudo
19
+ * process group (Windows has no POSIX groups), and taskkill tree signalling.
20
+ * The koffi bindings load lazily so
21
+ * non-Windows processes never touch Win32 libraries; all decision logic takes
22
+ * an injectable internals boundary so suites can pin it on any host.
23
+ * @module dsh-subprocess-local/windows-inspector
24
+ */
25
+ /**
26
+ * Walk a process table from one root in children-first order, retaining only
27
+ * members whose start identity is readable (unreadable members are detector
28
+ * misses, exactly like an unreadable `/proc` entry on Linux).
29
+ * @param entries - the process table snapshot.
30
+ * @param rootPid - the tree root to descend from.
31
+ * @param started - creation-time identity resolver for one member.
32
+ * @returns the root and its current transitive descendants, children first.
33
+ */
34
+ function windowsProcessTree(entries, rootPid, started) {
35
+ const root = new Map(entries.map((entry) => [entry.pid, entry])).get(rootPid);
36
+ if (root === void 0) return [];
37
+ const byParent = /* @__PURE__ */ new Map();
38
+ for (const entry of entries) {
39
+ const children = byParent.get(entry.parentPid) ?? [];
40
+ children.push(entry);
41
+ byParent.set(entry.parentPid, children);
42
+ }
43
+ const visited = /* @__PURE__ */ new Set();
44
+ const result = [];
45
+ const visit = (entry) => {
46
+ if (visited.has(entry.pid)) return;
47
+ visited.add(entry.pid);
48
+ for (const child of byParent.get(entry.pid) ?? []) visit(child);
49
+ const identity = started(entry.pid);
50
+ if (identity !== void 0) result.push({
51
+ pid: entry.pid,
52
+ started: identity
53
+ });
54
+ };
55
+ visit(root);
56
+ return result;
57
+ }
58
+ /**
59
+ * Windows {@link ProcessInspector}. The shell pid stands in for a foreground
60
+ * process group: it is a stable pseudo-group that lets the prompt-marker
61
+ * readiness path compare foreground identities, while every actual signal
62
+ * targets the console-wide tree through taskkill (SIGINT is delivered by the
63
+ * terminal handle as a `\x03` input write and never reaches this layer).
64
+ */
65
+ var WindowsProcessInspector = class {
66
+ internals;
67
+ constructor(internals = defaultWindowsProcessInternals()) {
68
+ this.internals = internals;
69
+ }
70
+ foregroundPgid(shellPid) {
71
+ return shellPid;
72
+ }
73
+ isStdinWaiting(_pgid) {
74
+ return false;
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
+ isAlive(identity) {
83
+ const state = this.internals.processState(identity.pid);
84
+ return state?.active === true && state.started === identity.started;
85
+ }
86
+ signalGroup(pgid, signal) {
87
+ this.internals.taskkill(pgid, signal === "SIGKILL");
88
+ }
89
+ signalProcess(identity, signal) {
90
+ if (this.isAlive(identity)) this.internals.taskkill(identity.pid, signal === "SIGKILL");
91
+ }
92
+ };
93
+ /**
94
+ * Create the Windows process inspector.
95
+ * @param internals - injectable process operations; defaults to the koffi-backed table.
96
+ * @returns the Windows inspector.
97
+ */
98
+ function createWindowsProcessInspector(internals = defaultWindowsProcessInternals()) {
99
+ return new WindowsProcessInspector(internals);
100
+ }
101
+ /** Terminate one Windows process tree with taskkill, contained like POSIX group signalling. */
102
+ function taskkillTree(pid, force) {
103
+ if (pid <= 0) return;
104
+ spawnSync("taskkill", [
105
+ "/PID",
106
+ String(pid),
107
+ "/T",
108
+ ...force ? ["/F"] : []
109
+ ], { stdio: "ignore" });
110
+ }
111
+ /**
112
+ * True for NULL and INVALID_HANDLE_VALUE returns from Win32 handle APIs.
113
+ * @param value - a handle as koffi may hand it back (pointer, null, or 0n).
114
+ * @returns whether the value signals an invalid handle.
115
+ */
116
+ function isInvalidHandle(value) {
117
+ if (value === null || value === void 0) return true;
118
+ const asBigInt = value;
119
+ return asBigInt === 0n || asBigInt === 18446744073709551615n || asBigInt === -1n;
120
+ }
121
+ const PVOID = koffi.pointer("void");
122
+ /**
123
+ * Resolve the koffi Win32 struct types once. Registration is lazy and cached
124
+ * because koffi's type registry is global per process: test runners that
125
+ * re-evaluate this module (a hoisted `vi.mock` re-imports the graph) must not
126
+ * re-register the names.
127
+ */
128
+ function win32Structs() {
129
+ if (cachedStructs !== void 0) return cachedStructs;
130
+ const PROCESSENTRY32W = koffi.struct("PROCESSENTRY32W", {
131
+ dwSize: "uint32",
132
+ cntUsage: "uint32",
133
+ th32ProcessID: "uint32",
134
+ th32DefaultHeapID: PVOID,
135
+ th32ModuleID: "uint32",
136
+ cCntThreads: "uint32",
137
+ th32ParentProcessID: "uint32",
138
+ pcPriClassBase: "int32",
139
+ dwFlags: "uint32",
140
+ szExeFile: koffi.array("char16", 260)
141
+ });
142
+ const FILETIME = koffi.struct("FILETIME", {
143
+ dwLowDateTime: "uint32",
144
+ dwHighDateTime: "uint32"
145
+ });
146
+ /* v8 ignore start -- a layout-mismatch guard fires only on ABI breakage; the windows-native suites exercise the real struct. */
147
+ if (PROCESSENTRY32W.size !== 568) throw new Error(`PROCESSENTRY32W layout mismatch: koffi computed ${PROCESSENTRY32W.size}, Windows headers say 568`);
148
+ /* v8 ignore stop */
149
+ cachedStructs = {
150
+ PROCESSENTRY32W,
151
+ FILETIME
152
+ };
153
+ return cachedStructs;
154
+ }
155
+ let cachedStructs;
156
+ const TH32CS_SNAPPROCESS = 2;
157
+ const WAIT_OBJECT_0 = 0;
158
+ const WAIT_TIMEOUT = 258;
159
+ let cachedBindings;
160
+ /**
161
+ * Resolve the lazy Win32 bindings (throws the first binding failure, fail-closed).
162
+ * @returns the cached binding table.
163
+ */
164
+ function win32Bindings() {
165
+ if (cachedBindings !== void 0) return cachedBindings;
166
+ const { PROCESSENTRY32W, FILETIME } = win32Structs();
167
+ const kernel32 = koffi.load("kernel32.dll");
168
+ const bind = (name, result, args) => kernel32.func("__stdcall", name, result, args);
169
+ cachedBindings = {
170
+ createToolhelp32Snapshot: bind("CreateToolhelp32Snapshot", PVOID, ["uint32", "uint32"]),
171
+ process32FirstW: bind("Process32FirstW", "int", [PVOID, koffi.pointer(PROCESSENTRY32W)]),
172
+ process32NextW: bind("Process32NextW", "int", [PVOID, koffi.pointer(PROCESSENTRY32W)]),
173
+ openProcess: bind("OpenProcess", PVOID, [
174
+ "uint32",
175
+ "int",
176
+ "uint32"
177
+ ]),
178
+ getProcessTimes: bind("GetProcessTimes", "int", [
179
+ PVOID,
180
+ koffi.pointer(FILETIME),
181
+ koffi.pointer(FILETIME),
182
+ koffi.pointer(FILETIME),
183
+ koffi.pointer(FILETIME)
184
+ ]),
185
+ waitForSingleObject: bind("WaitForSingleObject", "uint32", [PVOID, "uint32"]),
186
+ closeHandle: bind("CloseHandle", "int", [PVOID])
187
+ };
188
+ return cachedBindings;
189
+ }
190
+ /**
191
+ * Allocate koffi memory as a branded {@link NativePtr}; koffi's TS types are
192
+ * `any`, so the cast goes through `unknown` to keep the unsafe surface here.
193
+ * @param type - the koffi type to allocate.
194
+ * @param count - element count.
195
+ * @returns the branded allocation pointer.
196
+ */
197
+ function allocNative(type, count) {
198
+ return koffi.alloc(type, count);
199
+ }
200
+ /** Enumerate the current process table through Toolhelp32. */
201
+ function snapshotWindowsProcesses(bindings) {
202
+ const { PROCESSENTRY32W } = win32Structs();
203
+ const snapshot = bindings.createToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
204
+ /* v8 ignore next -- an invalid snapshot for the process flag is not producible through the public API;
205
+ the guard mirrors POSIX's unreadable-proc tolerance and isInvalidHandle is unit-tested. */
206
+ if (isInvalidHandle(snapshot)) return [];
207
+ const entries = [];
208
+ try {
209
+ const entry = allocNative(PROCESSENTRY32W, 1);
210
+ koffi.encode(entry, "uint32", PROCESSENTRY32W.size);
211
+ let ok = bindings.process32FirstW(snapshot, entry);
212
+ while (ok !== 0) {
213
+ const record = koffi.decode(entry, PROCESSENTRY32W);
214
+ entries.push({
215
+ pid: record.th32ProcessID,
216
+ parentPid: record.th32ParentProcessID
217
+ });
218
+ ok = bindings.process32NextW(snapshot, entry);
219
+ }
220
+ } finally {
221
+ bindings.closeHandle(snapshot);
222
+ }
223
+ return entries;
224
+ }
225
+ /** Read one process's creation identity and current wait state. */
226
+ function windowsProcessState(bindings, pid) {
227
+ const { FILETIME } = win32Structs();
228
+ const handle = bindings.openProcess(1052672, 0, pid);
229
+ if (isInvalidHandle(handle)) return void 0;
230
+ try {
231
+ const creation = allocNative(FILETIME, 1);
232
+ const exit = allocNative(FILETIME, 1);
233
+ const kernel = allocNative(FILETIME, 1);
234
+ const user = allocNative(FILETIME, 1);
235
+ /* v8 ignore next -- a GetProcessTimes failure after a successful open races process exit and
236
+ cannot be staged deterministically; the absent-process path is covered and the caller
237
+ treats undefined as a detector miss. */
238
+ if (bindings.getProcessTimes(handle, creation, exit, kernel, user) === 0) return void 0;
239
+ const record = koffi.decode(creation, FILETIME);
240
+ const wait = bindings.waitForSingleObject(handle, 0);
241
+ /* v8 ignore next -- an opened process handle has exactly one of these two
242
+ zero-time wait states; an unexpected Win32 failure is an unreadable process. */
243
+ if (wait !== WAIT_OBJECT_0 && wait !== WAIT_TIMEOUT) return void 0;
244
+ return {
245
+ started: `${record.dwHighDateTime}:${record.dwLowDateTime}`,
246
+ active: wait === WAIT_TIMEOUT
247
+ };
248
+ } finally {
249
+ bindings.closeHandle(handle);
250
+ }
251
+ }
252
+ /** The koffi-backed default internals; bindings resolve lazily on first use. */
253
+ function defaultWindowsProcessInternals() {
254
+ return {
255
+ snapshot: () => snapshotWindowsProcesses(win32Bindings()),
256
+ processState: (pid) => windowsProcessState(win32Bindings(), pid),
257
+ taskkill: taskkillTree
258
+ };
259
+ }
260
+ //#endregion
13
261
  //#region lib/types/process-inspector.js
14
262
  /** Platform process-table inspection for terminal readiness, signals, and teardown. */
15
263
  /* v8 ignore start -- thin OS bindings; injected logic is unit-tested and real platform composition exercises them. */
@@ -295,6 +543,7 @@ var MacProcessInspector = class extends PosixProcessInspector {
295
543
  function createProcessInspector(platform = process.platform, arch = process.arch, internals = DEFAULT_INTERNALS) {
296
544
  if (platform === "linux") return new LinuxProcessInspector(arch, internals);
297
545
  if (platform === "darwin") return new MacProcessInspector(internals);
546
+ if (platform === "win32") return createWindowsProcessInspector();
298
547
  throw new Error(`subprocess-local: terminal inspection is unsupported on platform ${platform}`);
299
548
  }
300
549
  //#endregion
@@ -724,6 +973,7 @@ var LocalTerminalHandle = class {
724
973
  terminal;
725
974
  inspector;
726
975
  graceMs;
976
+ platform;
727
977
  pid;
728
978
  output = new PassThrough();
729
979
  done;
@@ -739,11 +989,13 @@ var LocalTerminalHandle = class {
739
989
  * @param terminal - allocated node-pty process.
740
990
  * @param inspector - platform process/session operations.
741
991
  * @param graceMs - TERM-to-KILL and exit-wait grace.
992
+ * @param platform - host platform; defaults to the running platform, injectable for deterministic tests.
742
993
  */
743
- constructor(terminal, inspector, graceMs) {
994
+ constructor(terminal, inspector, graceMs, platform = process.platform) {
744
995
  this.terminal = terminal;
745
996
  this.inspector = inspector;
746
997
  this.graceMs = graceMs;
998
+ this.platform = platform;
747
999
  this.pid = terminal.pid;
748
1000
  this.rootIdentity = inspector.processTree(this.pid).find((member) => member.pid === this.pid);
749
1001
  this.done = this.outcome.promise;
@@ -777,6 +1029,13 @@ var LocalTerminalHandle = class {
777
1029
  const foreground = await this.inspectForeground();
778
1030
  if (foreground === void 0) throw new Error(`cannot resolve foreground process group for terminal ${this.pid}`);
779
1031
  if (signal === "SIGKILL" && foreground.processGroupId === this.pid) throw new Error("refusing to SIGKILL the terminal shell; terminate the terminal session instead");
1032
+ if (this.platform === "win32") {
1033
+ if (signal === "SIGINT") {
1034
+ this.terminal.write("");
1035
+ return foreground.processGroupId;
1036
+ }
1037
+ if (signal === "SIGTSTP" || signal === "SIGHUP") throw new Error(`signal ${signal} is unsupported on Windows; only SIGINT, SIGTERM, and SIGKILL are available`);
1038
+ }
780
1039
  this.inspector.signalGroup(foreground.processGroupId, signal);
781
1040
  return foreground.processGroupId;
782
1041
  }
@@ -862,6 +1121,10 @@ var LocalTerminalHandle = class {
862
1121
  return this.survivors(this.unionMembers(survivors, this.descendants()));
863
1122
  }
864
1123
  async stopShell() {
1124
+ if (this.platform === "win32") {
1125
+ await this.stopShellWindows();
1126
+ return;
1127
+ }
865
1128
  if (!this.exited) {
866
1129
  try {
867
1130
  this.terminal.kill("SIGTERM");
@@ -876,15 +1139,54 @@ var LocalTerminalHandle = class {
876
1139
  }
877
1140
  if (!this.exited) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`);
878
1141
  }
1142
+ async stopShellWindows() {
1143
+ const shellGone = () => this.exited || this.rootIdentity !== void 0 && !this.inspector.isAlive(this.rootIdentity);
1144
+ if (!shellGone() && this.rootIdentity !== void 0) {
1145
+ this.inspector.signalProcess(this.rootIdentity, "SIGTERM");
1146
+ await this.waitForWindowsShellExit();
1147
+ }
1148
+ if (!shellGone() && this.rootIdentity === void 0) {
1149
+ try {
1150
+ this.terminal.kill();
1151
+ } catch (_topLevelAlreadyExitedDuringKill) {}
1152
+ await Promise.race([this.done.then(() => void 0), delay(this.graceMs)]);
1153
+ }
1154
+ if (!shellGone() && this.rootIdentity !== void 0) {
1155
+ this.inspector.signalProcess(this.rootIdentity, "SIGKILL");
1156
+ await this.waitForWindowsShellExit();
1157
+ }
1158
+ if (!shellGone()) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`);
1159
+ }
1160
+ async waitForWindowsShellExit() {
1161
+ const until = Date.now() + this.graceMs;
1162
+ while (!this.exited && Date.now() < until) {
1163
+ if (this.rootIdentity !== void 0 && !this.inspector.isAlive(this.rootIdentity)) return;
1164
+ await delay(Math.min(25, Math.max(1, until - Date.now())));
1165
+ }
1166
+ }
879
1167
  async closeOnce() {
880
1168
  let survivors = await this.stopDescendants();
881
1169
  if (survivors.length > 0) throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map((member) => member.pid).join(", ")}`);
882
1170
  await this.stopShell();
883
1171
  survivors = await this.stopDescendants();
884
1172
  if (survivors.length > 0) throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map((member) => member.pid).join(", ")}`);
1173
+ this.settleExitIfGone();
885
1174
  this.dataDisposable.dispose();
886
1175
  this.exitDisposable.dispose();
887
1176
  }
1177
+ settleExitIfGone() {
1178
+ if (this.platform !== "win32") return;
1179
+ if (this.exited) return;
1180
+ /* v8 ignore next -- stopShellWindows() verified the shell is gone or threw;
1181
+ the identity re-check is a defensive fence for a future caller. */
1182
+ if (this.rootIdentity !== void 0 && this.inspector.isAlive(this.rootIdentity)) return;
1183
+ this.exited = true;
1184
+ this.output.end();
1185
+ this.outcome.resolve({
1186
+ exitCode: null,
1187
+ signal: null
1188
+ });
1189
+ }
888
1190
  };
889
1191
  //#endregion
890
1192
  //#region lib/types/index.js
@@ -15,6 +15,7 @@ export declare class LocalTerminalHandle implements SubprocessTerminalHandle {
15
15
  private readonly terminal;
16
16
  private readonly inspector;
17
17
  private readonly graceMs;
18
+ private readonly platform;
18
19
  readonly pid: number;
19
20
  readonly output: PassThrough;
20
21
  readonly done: Promise<SubprocessOutcome>;
@@ -30,8 +31,9 @@ export declare class LocalTerminalHandle implements SubprocessTerminalHandle {
30
31
  * @param terminal - allocated node-pty process.
31
32
  * @param inspector - platform process/session operations.
32
33
  * @param graceMs - TERM-to-KILL and exit-wait grace.
34
+ * @param platform - host platform; defaults to the running platform, injectable for deterministic tests.
33
35
  */
34
- constructor(terminal: IPty, inspector: ProcessInspector, graceMs: number);
36
+ constructor(terminal: IPty, inspector: ProcessInspector, graceMs: number, platform?: NodeJS.Platform);
35
37
  write(data: string): Promise<void>;
36
38
  inspectForeground(): Promise<SubprocessTerminalForeground | undefined>;
37
39
  signalForeground(signal: SubprocessTerminalSignal): Promise<number>;
@@ -50,6 +52,9 @@ export declare class LocalTerminalHandle implements SubprocessTerminalHandle {
50
52
  private unionMembers;
51
53
  private stopDescendants;
52
54
  private stopShell;
55
+ private stopShellWindows;
56
+ private waitForWindowsShellExit;
53
57
  private closeOnce;
58
+ private settleExitIfGone;
54
59
  }
55
60
  //# sourceMappingURL=terminal.d.ts.map
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Windows process-table operations for terminal readiness, signalling, and
3
+ * teardown: Toolhelp32 snapshot enumeration with GetProcessTimes creation-time
4
+ * identity and process-handle wait-state liveness, the shell pid as a pseudo
5
+ * process group (Windows has no POSIX groups), and taskkill tree signalling.
6
+ * The koffi bindings load lazily so
7
+ * non-Windows processes never touch Win32 libraries; all decision logic takes
8
+ * an injectable internals boundary so suites can pin it on any host.
9
+ * @module dsh-subprocess-local/windows-inspector
10
+ */
11
+ import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess';
12
+ import type { ProcessIdentity, ProcessInspector } from './process-inspector.ts';
13
+ /** One Toolhelp32 process-table row. */
14
+ export interface ProcessEntry {
15
+ pid: number;
16
+ parentPid: number;
17
+ }
18
+ /** Creation identity plus the process object's current wait state. */
19
+ export interface WindowsProcessState {
20
+ /** GetProcessTimes creation identity used to fence PID reuse. */
21
+ started: string;
22
+ /** Whether a zero-time process-handle wait reports the process still running. */
23
+ active: boolean;
24
+ }
25
+ /** Injectable Windows process operations used by one local PTY session. */
26
+ export interface WindowsProcessInspectorInternals {
27
+ /** Enumerate the current process table (pid/parent pairs). */
28
+ snapshot(): ProcessEntry[];
29
+ /** Return one process's creation identity and wait state, or undefined when unreadable. */
30
+ processState(pid: number): WindowsProcessState | undefined;
31
+ /** Terminate one process tree; `force` maps to taskkill `/F`. */
32
+ taskkill(pid: number, force: boolean): void;
33
+ }
34
+ /**
35
+ * Walk a process table from one root in children-first order, retaining only
36
+ * members whose start identity is readable (unreadable members are detector
37
+ * misses, exactly like an unreadable `/proc` entry on Linux).
38
+ * @param entries - the process table snapshot.
39
+ * @param rootPid - the tree root to descend from.
40
+ * @param started - creation-time identity resolver for one member.
41
+ * @returns the root and its current transitive descendants, children first.
42
+ */
43
+ export declare function windowsProcessTree(entries: ProcessEntry[], rootPid: number, started: (pid: number) => string | undefined): ProcessIdentity[];
44
+ /**
45
+ * Windows {@link ProcessInspector}. The shell pid stands in for a foreground
46
+ * process group: it is a stable pseudo-group that lets the prompt-marker
47
+ * readiness path compare foreground identities, while every actual signal
48
+ * targets the console-wide tree through taskkill (SIGINT is delivered by the
49
+ * terminal handle as a `\x03` input write and never reaches this layer).
50
+ */
51
+ export declare class WindowsProcessInspector implements ProcessInspector {
52
+ private readonly internals;
53
+ constructor(internals?: WindowsProcessInspectorInternals);
54
+ foregroundPgid(shellPid: number): number;
55
+ isStdinWaiting(_pgid: number): boolean;
56
+ processTree(rootPid: number): ProcessIdentity[];
57
+ processSession(_sessionId: number): ProcessIdentity[];
58
+ isAlive(identity: ProcessIdentity): boolean;
59
+ signalGroup(pgid: number, signal: SubprocessTerminalSignal): void;
60
+ signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void;
61
+ }
62
+ /**
63
+ * Create the Windows process inspector.
64
+ * @param internals - injectable process operations; defaults to the koffi-backed table.
65
+ * @returns the Windows inspector.
66
+ */
67
+ export declare function createWindowsProcessInspector(internals?: WindowsProcessInspectorInternals): WindowsProcessInspector;
68
+ declare const nativePtr: unique symbol;
69
+ /** Koffi 3 native pointer (a BigInt address), branded so it cannot silently enter numeric contexts. */
70
+ export type NativePtr = bigint & {
71
+ readonly [nativePtr]: true;
72
+ };
73
+ /**
74
+ * True for NULL and INVALID_HANDLE_VALUE returns from Win32 handle APIs.
75
+ * @param value - a handle as koffi may hand it back (pointer, null, or 0n).
76
+ * @returns whether the value signals an invalid handle.
77
+ */
78
+ export declare function isInvalidHandle(value: NativePtr | null | undefined): boolean;
79
+ export {};
80
+ //# sourceMappingURL=windows-inspector.d.ts.map
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.0-rc.7",
4
+ "version": "0.1.0-rc.8",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -33,20 +33,21 @@
33
33
  ],
34
34
  "license": "MIT",
35
35
  "peerDependencies": {
36
- "@deepseek-ai/dsh-subprocess": "^0.1.0-rc.7",
37
- "@deepseek-ai/dsh-timeout": "^0.1.0-rc.7",
38
- "@deepseek-ai/cordis": "^4.0.1",
39
- "@deepseek-ai/dsh-invariants": "^0.1.0-rc.7"
36
+ "@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
37
+ "@deepseek-ai/dsh-subprocess": "^0.1.0-rc.8",
38
+ "@deepseek-ai/dsh-timeout": "^0.1.0-rc.8",
39
+ "@deepseek-ai/cordis": "^4.0.1"
40
40
  },
41
41
  "dependencies": {
42
+ "koffi": "^3.1.0",
42
43
  "node-pty": "1.2.0-beta.15"
43
44
  },
44
45
  "devDependencies": {
45
- "@deepseek-ai/dsh-loader-smoke": "^0.1.0-rc.7",
46
- "@deepseek-ai/dsh-invariants": "^0.1.0-rc.7",
47
- "@deepseek-ai/dsh-timeout": "^0.1.0-rc.7",
48
- "@deepseek-ai/dsh-subprocess": "^0.1.0-rc.7",
49
- "@deepseek-ai/cordis": "^4.0.1"
46
+ "@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
47
+ "@deepseek-ai/dsh-loader-smoke": "^0.1.0-rc.8",
48
+ "@deepseek-ai/dsh-timeout": "^0.1.0-rc.8",
49
+ "@deepseek-ai/cordis": "^4.0.1",
50
+ "@deepseek-ai/dsh-subprocess": "^0.1.0-rc.8"
50
51
  },
51
52
  "scripts": {
52
53
  "postinstall": "node scripts/ensure-spawn-helper.mjs"