@deepseek-ai/dsh-subprocess-local 0.0.1-rc.1

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/lib/index.js ADDED
@@ -0,0 +1,961 @@
1
+ import { closeSync, constants, mkdtempSync, openSync, readFileSync, readSync, readdirSync, unlinkSync, writeSync } from "node:fs";
2
+ import { access, stat } from "node:fs/promises";
3
+ import { delimiter, extname, isAbsolute, join, resolve } from "node:path";
4
+ import * as nodePty from "node-pty";
5
+ import { SubprocessService, scrubbedParentEnv } from "@deepseek-ai/dsh-subprocess";
6
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
7
+ import { randomBytes } from "node:crypto";
8
+ import { constants as constants$1, tmpdir } from "node:os";
9
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
10
+ import { MAX_TIMER_DELAY_MS } from "@deepseek-ai/dsh-timeout";
11
+ import { Buffer as Buffer$1 } from "node:buffer";
12
+ import { PassThrough } from "node:stream";
13
+ //#region lib/types/process-inspector.js
14
+ /** Platform process-table inspection for terminal readiness, signals, and teardown. */
15
+ /* v8 ignore start -- thin OS bindings; injected logic is unit-tested and real platform composition exercises them. */
16
+ const DEFAULT_INTERNALS = {
17
+ readFile: (path) => readFileSync(path, "utf8"),
18
+ readDir: (path) => readdirSync(path),
19
+ open: (path) => openSync(path, "r"),
20
+ read: (fd, buffer, length, position) => readSync(fd, buffer, 0, length, position),
21
+ close: closeSync,
22
+ exec: (file, args) => execFileSync(file, args, { encoding: "utf8" }),
23
+ kill: (pid, signal) => process.kill(pid, signal)
24
+ };
25
+ /**
26
+ * Parse fields used from Linux `/proc/<pid>/stat`, including parenthesized comm text.
27
+ * @param text - complete stat line.
28
+ * @returns Parsed identity/group fields, or undefined for malformed input.
29
+ */
30
+ function parseProcStat(text) {
31
+ const open = text.indexOf("(");
32
+ const close = text.lastIndexOf(")");
33
+ if (open <= 0 || close <= open) return void 0;
34
+ const pid = Number(text.slice(0, open).trim());
35
+ const rest = text.slice(close + 2).trim().split(/\s+/);
36
+ const state = rest[0] || "";
37
+ const parentPid = Number(rest[1]);
38
+ const pgrp = Number(rest[2]);
39
+ const session = Number(rest[3]);
40
+ const tpgid = Number(rest[5]);
41
+ const started = rest[19];
42
+ if (![
43
+ pid,
44
+ parentPid,
45
+ pgrp,
46
+ session,
47
+ tpgid
48
+ ].every(Number.isSafeInteger) || state.length !== 1 || started === void 0) return void 0;
49
+ return {
50
+ pid,
51
+ parentPid,
52
+ pgrp,
53
+ session,
54
+ state,
55
+ tpgid,
56
+ started
57
+ };
58
+ }
59
+ function readLinuxStat(internals, pid) {
60
+ try {
61
+ return parseProcStat(internals.readFile(`/proc/${pid}/stat`));
62
+ } catch (_unreadableProcEntry) {
63
+ return;
64
+ }
65
+ }
66
+ /**
67
+ * Report whether a Linux process group has an executing member. `false`
68
+ * means the group contains only zombie/dead entries; `undefined` means the
69
+ * process table could not prove either outcome.
70
+ * @param processGroupId - POSIX process-group id to inspect.
71
+ * @param internals - injectable process-table operations.
72
+ * @returns Live-member presence, or `undefined` when unavailable/absent.
73
+ */
74
+ function linuxProcessGroupHasLiveMembers(processGroupId, internals = DEFAULT_INTERNALS) {
75
+ let entries;
76
+ try {
77
+ entries = internals.readDir("/proc");
78
+ } catch (_unreadableProcDirectory) {
79
+ return;
80
+ }
81
+ let matched = false;
82
+ for (const entry of entries) {
83
+ if (!/^\d+$/.test(entry)) continue;
84
+ const stat = readLinuxStat(internals, Number(entry));
85
+ if (stat?.pgrp !== processGroupId) continue;
86
+ matched = true;
87
+ if (!/^[ZXx]$/.test(stat.state)) return true;
88
+ }
89
+ return matched ? false : void 0;
90
+ }
91
+ function numericEntries(internals, path) {
92
+ try {
93
+ return internals.readDir(path).filter((entry) => /^\d+$/.test(entry)).map(Number);
94
+ } catch (_unreadableProcDirectory) {
95
+ return [];
96
+ }
97
+ }
98
+ function readSyscall(internals, pid, tid) {
99
+ try {
100
+ const text = internals.readFile(`/proc/${pid}/task/${tid}/syscall`).trim();
101
+ if (text === "running" || text.startsWith("-1 ")) return void 0;
102
+ const fields = text.split(/\s+/);
103
+ const number = Number(fields[0]);
104
+ const args = fields.slice(1, 7).map((field) => Number.parseInt(field, 16));
105
+ if (!Number.isSafeInteger(number) || args.some((value) => !Number.isSafeInteger(value))) return void 0;
106
+ return {
107
+ number,
108
+ args
109
+ };
110
+ } catch (_unreadableSyscall) {
111
+ return;
112
+ }
113
+ }
114
+ function readMemory(internals, pid, address, length) {
115
+ let fd;
116
+ try {
117
+ fd = internals.open(`/proc/${pid}/mem`);
118
+ const buffer = Buffer.alloc(length);
119
+ const count = internals.read(fd, buffer, length, address);
120
+ return buffer.subarray(0, count);
121
+ } catch (_unreadableProcessMemory) {
122
+ return;
123
+ } finally {
124
+ if (fd !== void 0) internals.close(fd);
125
+ }
126
+ }
127
+ function fdSetHasStdin(internals, pid, address) {
128
+ return address !== 0 && (readMemory(internals, pid, address, 8)?.[0] ?? 0) % 2 === 1;
129
+ }
130
+ function pollHasStdin(internals, pid, address, count) {
131
+ if (address === 0 || count <= 0) return false;
132
+ const memory = readMemory(internals, pid, address, Math.min(count, 1024) * 8);
133
+ if (memory === void 0) return false;
134
+ for (let offset = 0; offset + 8 <= memory.length; offset += 8) if (memory.readInt32LE(offset) === 0 && (memory.readInt16LE(offset + 4) & 1) !== 0) return true;
135
+ return false;
136
+ }
137
+ function epollHasStdin(internals, pid, epfd) {
138
+ try {
139
+ return internals.readFile(`/proc/${pid}/fdinfo/${epfd}`).split("\n").some((line) => /^tfd:\s+0\b/.test(line.trim()));
140
+ } catch (_unreadableFdInfo) {
141
+ return false;
142
+ }
143
+ }
144
+ const SYSCALLS = {
145
+ x64: {
146
+ read: 0,
147
+ select: 23,
148
+ pselect: 270,
149
+ poll: 7,
150
+ ppoll: 271,
151
+ epollWait: 232,
152
+ epollPwait: 281
153
+ },
154
+ arm64: {
155
+ read: 63,
156
+ pselect: 72,
157
+ ppoll: 73,
158
+ epollPwait: 22
159
+ }
160
+ };
161
+ function syscallWaitsOnStdin(internals, pid, syscall, table) {
162
+ const [a0 = 0, a1 = 0, a2 = 0] = syscall.args;
163
+ if (syscall.number === table.read) return a0 === 0;
164
+ if (syscall.number === table.select || syscall.number === table.pselect) return a0 >= 1 && fdSetHasStdin(internals, pid, a1);
165
+ if (syscall.number === table.poll || syscall.number === table.ppoll) return a1 >= 1 && pollHasStdin(internals, pid, a0, a1);
166
+ if (syscall.number === table.epollWait || syscall.number === table.epollPwait) return a2 >= 1 && epollHasStdin(internals, pid, a0);
167
+ return false;
168
+ }
169
+ var PosixProcessInspector = class {
170
+ internals;
171
+ constructor(internals) {
172
+ this.internals = internals;
173
+ }
174
+ signalGroup(pgid, signal) {
175
+ this.internals.kill(-pgid, signal);
176
+ }
177
+ signalProcess(identity, signal) {
178
+ if (this.isAlive(identity)) this.internals.kill(identity.pid, signal);
179
+ }
180
+ };
181
+ function processTree(entries, rootPid) {
182
+ const root = new Map(entries.map((entry) => [entry.pid, entry])).get(rootPid);
183
+ if (root === void 0) return [];
184
+ const byParent = /* @__PURE__ */ new Map();
185
+ for (const entry of entries) {
186
+ const children = byParent.get(entry.parentPid) ?? [];
187
+ children.push(entry);
188
+ byParent.set(entry.parentPid, children);
189
+ }
190
+ const visited = /* @__PURE__ */ new Set();
191
+ const result = [];
192
+ const visit = (entry) => {
193
+ if (visited.has(entry.pid)) return;
194
+ visited.add(entry.pid);
195
+ for (const child of byParent.get(entry.pid) ?? []) visit(child);
196
+ result.push({
197
+ pid: entry.pid,
198
+ started: entry.started
199
+ });
200
+ };
201
+ visit(root);
202
+ return result;
203
+ }
204
+ var LinuxProcessInspector = class extends PosixProcessInspector {
205
+ arch;
206
+ constructor(arch, internals) {
207
+ super(internals);
208
+ this.arch = arch;
209
+ }
210
+ foregroundPgid(shellPid) {
211
+ const tpgid = readLinuxStat(this.internals, shellPid)?.tpgid;
212
+ return tpgid !== void 0 && tpgid > 0 ? tpgid : void 0;
213
+ }
214
+ isStdinWaiting(pgid) {
215
+ const table = SYSCALLS[this.arch];
216
+ if (table === void 0) return false;
217
+ for (const pid of numericEntries(this.internals, "/proc")) {
218
+ if (readLinuxStat(this.internals, pid)?.pgrp !== pgid) continue;
219
+ for (const tid of numericEntries(this.internals, `/proc/${pid}/task`)) {
220
+ const syscall = readSyscall(this.internals, pid, tid);
221
+ if (syscall !== void 0 && syscallWaitsOnStdin(this.internals, pid, syscall, table)) return true;
222
+ }
223
+ }
224
+ return false;
225
+ }
226
+ processTree(rootPid) {
227
+ return processTree(numericEntries(this.internals, "/proc").flatMap((pid) => {
228
+ const stat = readLinuxStat(this.internals, pid);
229
+ return stat === void 0 ? [] : [{
230
+ pid,
231
+ parentPid: stat.parentPid,
232
+ started: stat.started
233
+ }];
234
+ }), rootPid);
235
+ }
236
+ processSession(sessionId) {
237
+ return numericEntries(this.internals, "/proc").flatMap((pid) => {
238
+ const stat = readLinuxStat(this.internals, pid);
239
+ return stat?.session === sessionId ? [{
240
+ pid,
241
+ started: stat.started
242
+ }] : [];
243
+ });
244
+ }
245
+ isAlive(identity) {
246
+ const stat = readLinuxStat(this.internals, identity.pid);
247
+ return stat?.started === identity.started && !/^[ZXx]$/.test(stat.state);
248
+ }
249
+ };
250
+ function macProcessTable(internals) {
251
+ return internals.exec("/bin/ps", ["-axo", "pid=,ppid=,lstart="]).split("\n").flatMap((line) => {
252
+ const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(line);
253
+ if (match?.[1] === void 0 || match[2] === void 0 || match[3] === void 0) return [];
254
+ return [{
255
+ pid: Number(match[1]),
256
+ parentPid: Number(match[2]),
257
+ started: match[3]
258
+ }];
259
+ });
260
+ }
261
+ var MacProcessInspector = class extends PosixProcessInspector {
262
+ foregroundPgid(shellPid) {
263
+ try {
264
+ const value = Number(this.internals.exec("/bin/ps", [
265
+ "-o",
266
+ "tpgid=",
267
+ "-p",
268
+ String(shellPid)
269
+ ]).trim());
270
+ return Number.isSafeInteger(value) && value > 0 ? value : void 0;
271
+ } catch (_missingProcess) {
272
+ return;
273
+ }
274
+ }
275
+ isStdinWaiting(_pgid) {
276
+ return false;
277
+ }
278
+ processTree(rootPid) {
279
+ return processTree(macProcessTable(this.internals), rootPid);
280
+ }
281
+ processSession(_sessionId) {
282
+ return [];
283
+ }
284
+ isAlive(identity) {
285
+ return macProcessTable(this.internals).some((entry) => entry.pid === identity.pid && entry.started === identity.started);
286
+ }
287
+ };
288
+ /**
289
+ * Create the supported platform inspector or fail at plugin load.
290
+ * @param platform - target Node platform.
291
+ * @param arch - target CPU architecture for Linux syscall numbers.
292
+ * @param internals - filesystem/process boundary, injectable for deterministic tests.
293
+ * @returns Platform process inspector.
294
+ */
295
+ function createProcessInspector(platform = process.platform, arch = process.arch, internals = DEFAULT_INTERNALS) {
296
+ if (platform === "linux") return new LinuxProcessInspector(arch, internals);
297
+ if (platform === "darwin") return new MacProcessInspector(internals);
298
+ throw new Error(`subprocess-local: terminal inspection is unsupported on platform ${platform}`);
299
+ }
300
+ //#endregion
301
+ //#region lib/types/spawn.js
302
+ /**
303
+ * Process plumbing for the local subprocess service: detached process-tree
304
+ * spawn with per-stream stdio dispositions, tail-keep collection with spill
305
+ * files, tree-scoped signalling (POSIX groups; Windows taskkill), and the
306
+ * SIGTERM→SIGKILL escalation. This layer reacts to an abort signal; callers
307
+ * own deadlines, teardown ladders, and cause classification.
308
+ * @module dsh-subprocess-local/spawn
309
+ */
310
+ /**
311
+ * Build a child environment: explicit caller entries override the scrubbed
312
+ * parent base using the target platform's environment-key semantics. A string
313
+ * deliberately restores or overrides an entry; an explicit `undefined`
314
+ * tombstone removes an ordinary ambient entry.
315
+ * @param extra - explicit caller entries and tombstones, merged after the scrub.
316
+ * @returns the environment to hand to `spawn` for the child process.
317
+ */
318
+ function childEnv(extra) {
319
+ const env = scrubbedParentEnv();
320
+ if (process.platform !== "win32") return {
321
+ ...env,
322
+ ...extra
323
+ };
324
+ let entries = Object.entries(env);
325
+ for (const [key, value] of Object.entries(extra ?? {})) {
326
+ const normalized = key.toUpperCase();
327
+ entries = entries.filter(([inherited]) => inherited.toUpperCase() !== normalized);
328
+ entries.push([key, value]);
329
+ }
330
+ return Object.fromEntries(entries);
331
+ }
332
+ /**
333
+ * Liveness-poll cadence for tree-exit waits. The timer stays ref'd: an
334
+ * awaited teardown must keep the event loop alive until the tree really
335
+ * exits, or the parent can exit while claiming quiescence and orphan the
336
+ * survivors it promised to reap.
337
+ */
338
+ function sleepTick() {
339
+ return setTimeout$1(15);
340
+ }
341
+ let spillCounter = 0;
342
+ let defaultSpillDir;
343
+ /**
344
+ * The default spill location: a private (0700) per-process directory under
345
+ * the OS tmpdir, created lazily. Predictable world-readable paths would let
346
+ * other local users read command output or pre-create symlinks.
347
+ */
348
+ function privateSpillDir() {
349
+ defaultSpillDir ??= mkdtempSync(join(tmpdir(), "dsh-subprocess-"));
350
+ return defaultSpillDir;
351
+ }
352
+ /**
353
+ * Collects one stream with a bounded in-memory tail. With a spill cap, on
354
+ * first overflow a spill file is created and every chunk (including those
355
+ * already collected) is appended there while the full stream remains within
356
+ * the cap; without one, only the in-memory tail is ever retained (the
357
+ * diagnostic-tail shape — a language server's stderr).
358
+ *
359
+ * Tail-keep rationale (pi/OpenCode): errors and final results cluster at the
360
+ * end of command output; the spill file covers the head.
361
+ */
362
+ var OutputCollector = class {
363
+ maxBytes;
364
+ maxSpillBytes;
365
+ label;
366
+ spillDir;
367
+ chunks = [];
368
+ bytes = 0;
369
+ dropped = false;
370
+ spillFd;
371
+ spillFile;
372
+ spillDisabled;
373
+ /** Total bytes ever pushed (not just retained). */
374
+ total = 0;
375
+ constructor(maxBytes, maxSpillBytes, label, spillDir) {
376
+ this.maxBytes = maxBytes;
377
+ this.maxSpillBytes = maxSpillBytes;
378
+ this.label = label;
379
+ this.spillDir = spillDir;
380
+ this.spillDisabled = maxSpillBytes === void 0;
381
+ }
382
+ /**
383
+ * Ingest one stream chunk, counting it toward the whole-stream total. On
384
+ * first overflow of the in-memory cap a spill file is opened (when spilling
385
+ * is enabled) and every chunk (already-collected ones included) is appended
386
+ * there from then on; the in-memory tail then drops whole chunks from its
387
+ * head (or the head of a single over-cap chunk) until it fits the cap again.
388
+ * @param chunk - the raw bytes from one stream 'data' event.
389
+ */
390
+ push(chunk) {
391
+ this.total += chunk.length;
392
+ const overflows = this.bytes + chunk.length > this.maxBytes;
393
+ if (!this.spillDisabled && (overflows || this.spillFd !== void 0)) this.spillAll(chunk);
394
+ this.chunks.push(chunk);
395
+ this.bytes += chunk.length;
396
+ while (this.bytes > this.maxBytes) {
397
+ const head = this.chunks[0];
398
+ const excess = this.bytes - this.maxBytes;
399
+ if (head.length <= excess) {
400
+ this.chunks.shift();
401
+ this.bytes -= head.length;
402
+ } else {
403
+ this.chunks[0] = head.subarray(excess);
404
+ this.bytes -= excess;
405
+ }
406
+ this.dropped = true;
407
+ }
408
+ }
409
+ /** Open the spill file lazily and append `chunk` (and any prior chunks once). */
410
+ spillAll(chunk) {
411
+ if (this.maxSpillBytes !== void 0 && this.total > this.maxSpillBytes) {
412
+ this.discardSpill();
413
+ return;
414
+ }
415
+ if (this.spillFd === void 0) {
416
+ this.spillFile = join(this.spillDir, `dsh-subprocess-${process.pid}-${++spillCounter}-${randomBytes(6).toString("hex")}-${this.label}.log`);
417
+ this.spillFd = openSync(this.spillFile, "wx", 384);
418
+ for (const prior of this.chunks) writeSync(this.spillFd, prior);
419
+ }
420
+ writeSync(this.spillFd, chunk);
421
+ }
422
+ /** Stop spilling and remove the file once it can no longer hold the complete stream. */
423
+ discardSpill() {
424
+ const fd = this.spillFd;
425
+ const file = this.spillFile;
426
+ this.spillFd = void 0;
427
+ this.spillFile = void 0;
428
+ this.spillDisabled = true;
429
+ if (fd !== void 0) try {
430
+ closeSync(fd);
431
+ } catch {
432
+ this.spillFd = fd;
433
+ }
434
+ if (file !== void 0) try {
435
+ unlinkSync(file);
436
+ } catch {}
437
+ }
438
+ /**
439
+ * Incremental read in whole-stream byte coordinates: returns everything
440
+ * pushed since `fromByte`. When `fromByte` has already slid out of the
441
+ * in-memory tail window, the read is `lossy` — it returns the whole
442
+ * retained tail and the gap is only recoverable from the spill file.
443
+ * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read).
444
+ * @returns the delta text, the offset for the next read, the `lossy` flag, and the spill path when one was created.
445
+ */
446
+ readFrom(fromByte) {
447
+ const windowStart = this.total - this.bytes;
448
+ const buffer = Buffer.concat(this.chunks);
449
+ const lossy = fromByte < windowStart;
450
+ return {
451
+ text: (lossy ? buffer : buffer.subarray(fromByte - windowStart)).toString("utf8"),
452
+ nextOffset: this.total,
453
+ lossy,
454
+ ...this.spillFile !== void 0 ? { spillPath: this.spillFile } : {}
455
+ };
456
+ }
457
+ /**
458
+ * Close the spill file once the stream has ended. A failed close (delayed
459
+ * writeback fault) stops advertising the spill path — the file may be
460
+ * missing its tail — while every in-memory read keeps working. Idempotent;
461
+ * the spawn path seals both collectors at settlement so reads after exit
462
+ * never point at a still-open file.
463
+ */
464
+ seal() {
465
+ if (this.spillFd === void 0) return;
466
+ try {
467
+ closeSync(this.spillFd);
468
+ } catch {
469
+ this.spillFile = void 0;
470
+ }
471
+ this.spillFd = void 0;
472
+ }
473
+ /**
474
+ * Seal the spill file and return the final output.
475
+ * @returns the final collected output: tail text, truncation flag, and the spill path when intact.
476
+ */
477
+ finalize() {
478
+ this.seal();
479
+ return {
480
+ text: Buffer.concat(this.chunks).toString("utf8"),
481
+ truncated: this.dropped,
482
+ ...this.spillFile !== void 0 ? { spillPath: this.spillFile } : {}
483
+ };
484
+ }
485
+ };
486
+ /**
487
+ * Terminate one Windows process tree with `taskkill /T /F`. Contained like
488
+ * POSIX group signalling — delivery races tree exit, so an absent tree, a
489
+ * nonzero status, or a missing taskkill binary must not break idempotent
490
+ * teardown.
491
+ * @param pid - root process id; non-positive is a no-op.
492
+ */
493
+ function taskkillProcessTree(pid) {
494
+ if (pid <= 0) return;
495
+ spawnSync("taskkill", [
496
+ "/PID",
497
+ String(pid),
498
+ "/T",
499
+ "/F"
500
+ ], { stdio: "ignore" });
501
+ }
502
+ /**
503
+ * Signal a detached process tree with platform-correct semantics: POSIX
504
+ * signals the negative process-group id and falls back to the direct child
505
+ * when the group is gone; Windows terminates the tree via taskkill (any
506
+ * signal value force-terminates — Node maps signals to TerminateProcess).
507
+ */
508
+ function signalTree(platform, pid, sig, child, taskkill) {
509
+ if (platform === "win32") {
510
+ taskkill(pid);
511
+ return;
512
+ }
513
+ /* v8 ignore next -- kill/terminate gate on treeAlive(), which is false for pid -1; this guard protects direct callers only. */
514
+ if (pid <= 0) return;
515
+ try {
516
+ process.kill(-pid, sig);
517
+ } catch {
518
+ /* v8 ignore start -- the fallback needs a live child whose group signal fails
519
+ (EPERM-style), which POSIX CI cannot stage; the swallow keeps teardown idempotent. */
520
+ try {
521
+ child.kill(sig);
522
+ } catch {}
523
+ }
524
+ }
525
+ /**
526
+ * Spawn one isolated detached process tree with the spec's per-stream stdio
527
+ * dispositions. Runtime exits resolve `done` as {@link SubprocessOutcome};
528
+ * only spawn failures reject.
529
+ * @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment.
530
+ * @param internals - test-only spill-directory, platform, and taskkill overrides.
531
+ * @returns live subprocess handle.
532
+ * @throws when `graceMs` cannot be represented by one Node timer.
533
+ */
534
+ function spawnSubprocess(spec, internals = {}) {
535
+ if (!Number.isFinite(spec.graceMs) || spec.graceMs <= 0 || spec.graceMs > MAX_TIMER_DELAY_MS) throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
536
+ const spillDir = internals.spillDir ?? privateSpillDir();
537
+ const platform = internals.platform ?? process.platform;
538
+ const taskkill = internals.taskkill ?? taskkillProcessTree;
539
+ const linuxGroupHasLiveMembers = internals.linuxProcessGroupHasLiveMembers ?? linuxProcessGroupHasLiveMembers;
540
+ if (spec.signal?.aborted) throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? "aborted")}`);
541
+ const [program, ...args] = spec.argv;
542
+ if (program === void 0 || program.length === 0) throw new Error("invalid argv: expected a non-empty program name at argv[0]");
543
+ const isCollect = (mode) => mode !== "pipe" && mode !== "inherit";
544
+ const outMode = spec.stdio.stdout;
545
+ const errMode = spec.stdio.stderr;
546
+ const stdinMode = spec.stdio.stdin;
547
+ const env = childEnv(spec.env);
548
+ const child = spawn(program, args, {
549
+ cwd: spec.cwd,
550
+ env,
551
+ stdio: [
552
+ stdinMode === "ignore" ? "ignore" : "pipe",
553
+ outMode === "inherit" ? "inherit" : "pipe",
554
+ errMode === "inherit" ? "inherit" : "pipe"
555
+ ],
556
+ detached: platform !== "win32"
557
+ });
558
+ const collectStream = (mode, stream, label) => {
559
+ if (!isCollect(mode) || stream === null) return void 0;
560
+ const collector = new OutputCollector(mode.maxBytes, mode.spill?.maxBytes, label, spillDir);
561
+ stream.on("data", (chunk) => {
562
+ collector.push(chunk);
563
+ });
564
+ return collector;
565
+ };
566
+ const stdoutCollector = collectStream(outMode, child.stdout, "stdout");
567
+ const stderrCollector = collectStream(errMode, child.stderr, "stderr");
568
+ let graceTimer;
569
+ let treeExitObserved = false;
570
+ let treeExitObservation;
571
+ let settled = false;
572
+ const pid = child.pid ?? -1;
573
+ /** Whether the detached tree's root (or POSIX group) is still alive. */
574
+ const treeAlive = () => {
575
+ /* v8 ignore next -- only a timer callback already queued when the observer settles can enter here;
576
+ the guard is the final defense against probing an id after its tree was confirmed absent. */
577
+ if (treeExitObserved) return false;
578
+ if (pid <= 0) return false;
579
+ if (platform === "win32") return child.exitCode === null && child.signalCode === null;
580
+ try {
581
+ process.kill(-pid, 0);
582
+ if (settled && platform === "linux" && linuxGroupHasLiveMembers(pid) === false) return false;
583
+ return true;
584
+ } catch (error) {
585
+ const code = error.code;
586
+ /* v8 ignore next 2 -- POSIX reports an absent group as ESRCH; child-reaping timing
587
+ makes observing the other arm platform-dependent. */
588
+ if (code === "ESRCH") return false;
589
+ /* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs
590
+ tree-lifecycle tests on POSIX hosts where absence reports ESRCH. */
591
+ if (code === "EPERM") return true;
592
+ return child.exitCode === null && child.signalCode === null;
593
+ }
594
+ };
595
+ /**
596
+ * Start or reuse the handle's single whole-tree exit observer. The first
597
+ * confirmed absence is a permanent no-more-signals boundary: it cancels a
598
+ * pending escalation before this process-group id can be reused.
599
+ */
600
+ const observeTreeExit = () => {
601
+ treeExitObservation ??= (async () => {
602
+ while (treeAlive()) await sleepTick();
603
+ treeExitObserved = true;
604
+ if (graceTimer !== void 0) clearTimeout(graceTimer);
605
+ graceTimer = void 0;
606
+ })();
607
+ return treeExitObservation;
608
+ };
609
+ const kill = (sig) => {
610
+ /* v8 ignore next -- the shared exit observer cancels the ordinary dead-tree timer;
611
+ this remains the timer/death race guard and cannot be staged deterministically. */
612
+ if (!treeAlive()) return;
613
+ signalTree(platform, pid, sig, child, taskkill);
614
+ };
615
+ const terminate = () => {
616
+ if (treeExitObserved || graceTimer !== void 0) return;
617
+ observeTreeExit();
618
+ if (treeExitObserved) return;
619
+ kill("SIGTERM");
620
+ graceTimer = setTimeout(() => {
621
+ kill("SIGKILL");
622
+ }, spec.graceMs);
623
+ };
624
+ const onAbort = () => {
625
+ terminate();
626
+ };
627
+ spec.signal?.addEventListener("abort", onAbort, { once: true });
628
+ if (typeof stdinMode === "object" && child.stdin !== null) {
629
+ child.stdin.on("error", () => {});
630
+ child.stdin.end(stdinMode.data);
631
+ }
632
+ const done = new Promise((resolve, reject) => {
633
+ let pipeDrainTimer;
634
+ const settle = (exitCode, signal) => {
635
+ if (settled) return;
636
+ settled = true;
637
+ if (stdoutCollector !== void 0) child.stdout?.destroy();
638
+ if (stderrCollector !== void 0) child.stderr?.destroy();
639
+ stdoutCollector?.seal();
640
+ stderrCollector?.seal();
641
+ cleanup();
642
+ resolve({
643
+ exitCode,
644
+ signal
645
+ });
646
+ };
647
+ child.on("error", (error) => {
648
+ settled = true;
649
+ cleanup();
650
+ reject(error);
651
+ });
652
+ child.on("exit", (exitCode, signal) => {
653
+ pipeDrainTimer = setTimeout(() => {
654
+ settle(exitCode, signal);
655
+ }, spec.graceMs);
656
+ });
657
+ child.on("close", settle);
658
+ function cleanup() {
659
+ if (pipeDrainTimer !== void 0) clearTimeout(pipeDrainTimer);
660
+ spec.signal?.removeEventListener("abort", onAbort);
661
+ }
662
+ });
663
+ const waitForExit = async (signal) => {
664
+ const observed = observeTreeExit();
665
+ if (treeExitObserved) return true;
666
+ if (signal?.aborted) return false;
667
+ if (signal === void 0) {
668
+ await observed;
669
+ return true;
670
+ }
671
+ const aborted = Promise.withResolvers();
672
+ const onAbort = () => {
673
+ aborted.resolve(false);
674
+ };
675
+ signal.addEventListener("abort", onAbort, { once: true });
676
+ /* v8 ignore next -- closes the event-loop race between the preceding aborted check and listener registration. */
677
+ if (signal.aborted) onAbort();
678
+ try {
679
+ return await Promise.race([observed.then(() => true), aborted.promise]);
680
+ } finally {
681
+ signal.removeEventListener("abort", onAbort);
682
+ }
683
+ };
684
+ return {
685
+ pid,
686
+ /* v8 ignore start -- pipe-mode fds exist on every spawn Node returns; the null-coalesces guard a nonconforming ChildProcess only. */
687
+ stdin: stdinMode === "pipe" ? child.stdin ?? void 0 : void 0,
688
+ stdout: outMode === "pipe" ? child.stdout ?? void 0 : void 0,
689
+ stderr: errMode === "pipe" ? child.stderr ?? void 0 : void 0,
690
+ /* v8 ignore stop */
691
+ collected: {
692
+ ...stdoutCollector !== void 0 ? { stdout: stdoutCollector } : {},
693
+ ...stderrCollector !== void 0 ? { stderr: stderrCollector } : {}
694
+ },
695
+ done,
696
+ terminate,
697
+ waitForExit
698
+ };
699
+ }
700
+ //#endregion
701
+ //#region lib/types/terminal.js
702
+ /** Local node-pty terminal-process implementation for the subprocess seam. */
703
+ function delay(ms) {
704
+ return new Promise((resolve) => setTimeout(resolve, ms));
705
+ }
706
+ function signalName(number) {
707
+ if (number === void 0 || number === 0) return null;
708
+ for (const [name, value] of Object.entries(constants$1.signals)) if (value === number) return name;
709
+ return null;
710
+ }
711
+ /**
712
+ * A local terminal whose process-session ownership stays below the PTY backend.
713
+ * The seam's terminate() promise — no write, inspection, or signal in flight
714
+ * after settlement — holds here without operation tracking only because every
715
+ * handle call completes synchronously under the hood (node-pty write, ps-based
716
+ * inspection). A first genuinely asynchronous step in any handle call must add
717
+ * the tracking a remote provider needs.
718
+ */
719
+ var LocalTerminalHandle = class {
720
+ terminal;
721
+ inspector;
722
+ graceMs;
723
+ pid;
724
+ output = new PassThrough();
725
+ done;
726
+ outcome = Promise.withResolvers();
727
+ dataDisposable;
728
+ exitDisposable;
729
+ cleanup;
730
+ exited = false;
731
+ trackedDescendants = [];
732
+ /** The spawned shell's start identity; scans stop adopting members once the root pid no longer carries it. */
733
+ rootIdentity;
734
+ /**
735
+ * @param terminal - allocated node-pty process.
736
+ * @param inspector - platform process/session operations.
737
+ * @param graceMs - TERM-to-KILL and exit-wait grace.
738
+ */
739
+ constructor(terminal, inspector, graceMs) {
740
+ this.terminal = terminal;
741
+ this.inspector = inspector;
742
+ this.graceMs = graceMs;
743
+ this.pid = terminal.pid;
744
+ this.rootIdentity = inspector.processTree(this.pid).find((member) => member.pid === this.pid);
745
+ this.done = this.outcome.promise;
746
+ this.dataDisposable = terminal.onData((data) => {
747
+ this.output.write(Buffer$1.from(data, "utf8"));
748
+ });
749
+ this.exitDisposable = terminal.onExit(({ exitCode, signal: exitSignal }) => {
750
+ if (this.exited) return;
751
+ this.exited = true;
752
+ this.output.end();
753
+ this.outcome.resolve({
754
+ exitCode: exitSignal === void 0 || exitSignal === 0 ? exitCode : null,
755
+ signal: signalName(exitSignal)
756
+ });
757
+ });
758
+ }
759
+ async write(data) {
760
+ if (this.exited) throw new Error("terminal process has exited");
761
+ this.terminal.write(data);
762
+ }
763
+ async inspectForeground() {
764
+ this.descendants();
765
+ const processGroupId = this.inspector.foregroundPgid(this.pid);
766
+ if (processGroupId === void 0) return void 0;
767
+ return {
768
+ processGroupId,
769
+ inputWaiting: this.inspector.isStdinWaiting(processGroupId)
770
+ };
771
+ }
772
+ async signalForeground(signal) {
773
+ const foreground = await this.inspectForeground();
774
+ if (foreground === void 0) throw new Error(`cannot resolve foreground process group for terminal ${this.pid}`);
775
+ if (signal === "SIGKILL" && foreground.processGroupId === this.pid) throw new Error("refusing to SIGKILL the terminal shell; terminate the terminal session instead");
776
+ this.inspector.signalGroup(foreground.processGroupId, signal);
777
+ return foreground.processGroupId;
778
+ }
779
+ terminate() {
780
+ if (this.cleanup !== void 0) return this.cleanup;
781
+ const cleanup = this.closeOnce();
782
+ this.cleanup = cleanup;
783
+ cleanup.catch(() => {
784
+ this.cleanup = void 0;
785
+ });
786
+ return cleanup;
787
+ }
788
+ survivors(members) {
789
+ return members.filter((member) => this.inspector.isAlive(member));
790
+ }
791
+ descendants() {
792
+ const tree = this.inspector.processTree(this.pid);
793
+ const root = tree.find((member) => member.pid === this.pid);
794
+ const rootVerified = this.rootIdentity !== void 0 && root !== void 0 && root.started === this.rootIdentity.started;
795
+ this.trackedDescendants = this.survivors(this.unionMembers(this.trackedDescendants, ...rootVerified ? [tree, this.inspector.processSession(this.pid)] : []).filter((member) => member.pid !== this.pid));
796
+ return this.trackedDescendants;
797
+ }
798
+ async waitForMembers(members) {
799
+ const until = Date.now() + this.graceMs;
800
+ let survivors = this.survivors(members);
801
+ while (survivors.length > 0 && Date.now() < until) {
802
+ await delay(Math.min(25, Math.max(1, until - Date.now())));
803
+ survivors = this.survivors(members);
804
+ }
805
+ return survivors;
806
+ }
807
+ signalMembers(members, signal) {
808
+ for (const member of members) try {
809
+ this.inspector.signalProcess(member, signal);
810
+ } catch (_alreadyExitedDuringSignal) {}
811
+ }
812
+ unionMembers(...groups) {
813
+ const members = [];
814
+ const seen = /* @__PURE__ */ new Set();
815
+ for (const group of groups) for (const member of group) {
816
+ const key = `${member.pid}:${member.started}`;
817
+ if (seen.has(key)) continue;
818
+ seen.add(key);
819
+ members.push(member);
820
+ }
821
+ return members;
822
+ }
823
+ async stopDescendants() {
824
+ const captured = this.descendants();
825
+ this.signalMembers(captured, "SIGTERM");
826
+ const capturedSurvivors = await this.waitForMembers(captured);
827
+ const members = this.unionMembers(capturedSurvivors, this.descendants());
828
+ this.signalMembers(members, "SIGKILL");
829
+ const survivors = await this.waitForMembers(members);
830
+ return this.survivors(this.unionMembers(survivors, this.descendants()));
831
+ }
832
+ async stopShell() {
833
+ if (!this.exited) {
834
+ try {
835
+ this.terminal.kill("SIGTERM");
836
+ } catch (_topLevelAlreadyExitedDuringTerm) {}
837
+ await Promise.race([this.done.then(() => void 0), delay(this.graceMs)]);
838
+ }
839
+ if (!this.exited) {
840
+ try {
841
+ this.terminal.kill("SIGKILL");
842
+ } catch (_topLevelAlreadyExitedDuringKill) {}
843
+ await Promise.race([this.done.then(() => void 0), delay(this.graceMs)]);
844
+ }
845
+ if (!this.exited) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`);
846
+ }
847
+ async closeOnce() {
848
+ let survivors = await this.stopDescendants();
849
+ if (survivors.length > 0) throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map((member) => member.pid).join(", ")}`);
850
+ await this.stopShell();
851
+ survivors = await this.stopDescendants();
852
+ if (survivors.length > 0) throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map((member) => member.pid).join(", ")}`);
853
+ this.dataDisposable.dispose();
854
+ this.exitDisposable.dispose();
855
+ }
856
+ };
857
+ //#endregion
858
+ //#region lib/types/index.js
859
+ /**
860
+ * Local Service provider for the subprocess capability seam. Each spawn is a detached
861
+ * process tree with the spec's per-stream stdio dispositions; disposal
862
+ * terminates and joins live trees. It has no config: every disposition and
863
+ * limit arrives on the spec, so the deployment-varying choices stay with the
864
+ * caller's config (the bash executor's, the LSP host's, …).
865
+ * @module @deepseek-ai/dsh-subprocess-local
866
+ */
867
+ /**
868
+ * Local subprocess service: detached process trees, Node-shaped stdio
869
+ * dispositions (raw pipes, inherit, bounded tail-keep collection with spill
870
+ * files), credential-scrubbed environment, and tree-scoped signalling with
871
+ * SIGTERM→grace→SIGKILL escalation.
872
+ */
873
+ var LocalSubprocessService = class extends SubprocessService {
874
+ /** Live handles retained only so disposal can terminate and join them. */
875
+ live = /* @__PURE__ */ new Set();
876
+ /** Live terminal sessions retained through whole-session quiescence. */
877
+ terminals = /* @__PURE__ */ new Set();
878
+ /** Test hook: spill and platform knobs forwarded to spawnSubprocess. */
879
+ internals = {};
880
+ /** Test hook for platform process inspection; production resolves lazily on terminal spawn. */
881
+ terminalInspector;
882
+ constructor(ctx) {
883
+ super(ctx);
884
+ ctx.effect(() => async () => {
885
+ const pending = [];
886
+ for (const handle of this.live) {
887
+ handle.terminate();
888
+ pending.push(handle.done.catch(() => {}).then(() => handle.waitForExit()));
889
+ }
890
+ for (const terminal of this.terminals) pending.push(terminal.terminate());
891
+ this.live.clear();
892
+ this.terminals.clear();
893
+ const failures = (await Promise.allSettled(pending)).flatMap((outcome) => outcome.status === "rejected" ? [outcome.reason] : []);
894
+ if (failures.length === 1) throw failures[0];
895
+ if (failures.length > 1) throw new AggregateError(failures, "local subprocess teardown failed");
896
+ }, "local subprocess teardown");
897
+ }
898
+ async resolveExecutable(command, env, signal) {
899
+ if (command.length === 0) throw new Error("subprocess-local: executable must be non-empty");
900
+ signal?.throwIfAborted();
901
+ const environment = childEnv(env);
902
+ const absolute = isAbsolute(command);
903
+ if (!absolute && (command.includes("/") || process.platform === "win32" && command.includes("\\"))) throw new Error(`subprocess-local: command ${JSON.stringify(command)} is a relative path; use an absolute path or a bare PATH name`);
904
+ const candidates = absolute ? [command] : this.executableCandidates(command, environment);
905
+ for (const candidate of candidates) {
906
+ signal?.throwIfAborted();
907
+ try {
908
+ if (!(await stat(candidate)).isFile()) continue;
909
+ await access(candidate, constants.X_OK);
910
+ signal?.throwIfAborted();
911
+ return candidate;
912
+ } catch {}
913
+ }
914
+ signal?.throwIfAborted();
915
+ throw new Error(absolute ? `subprocess-local: command ${JSON.stringify(command)} is not an executable file` : `subprocess-local: command ${JSON.stringify(command)} was not found on PATH`);
916
+ }
917
+ executableCandidates(command, env) {
918
+ const path = environmentValue(env, "PATH") ?? "";
919
+ const extensions = process.platform === "win32" && extname(command) === "" ? (environmentValue(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";") : [""];
920
+ return path.split(delimiter).flatMap((directory) => extensions.map((extension) => resolve(process.cwd(), directory, command + extension)));
921
+ }
922
+ spawn(spec) {
923
+ const handle = spawnSubprocess(spec, this.internals);
924
+ this.live.add(handle);
925
+ const release = () => handle.waitForExit().then(() => {
926
+ this.live.delete(handle);
927
+ });
928
+ handle.done.then(release, release);
929
+ return handle;
930
+ }
931
+ async spawnTerminal(spec) {
932
+ const file = spec.argv[0];
933
+ if (file === void 0 || file.length === 0) throw new Error("subprocess-local: terminal argv must contain a program");
934
+ spec.signal?.throwIfAborted();
935
+ const options = {
936
+ name: "dumb",
937
+ rows: spec.rows,
938
+ cols: spec.cols,
939
+ cwd: spec.cwd,
940
+ env: childEnv(spec.env)
941
+ };
942
+ const inspector = this.terminalInspector ?? createProcessInspector();
943
+ const handle = new LocalTerminalHandle(nodePty.spawn(file, [...spec.argv.slice(1)], options), inspector, spec.graceMs);
944
+ this.terminals.add(handle);
945
+ const release = async () => {
946
+ await handle.terminate();
947
+ this.terminals.delete(handle);
948
+ };
949
+ handle.done.then(release, release).catch(() => {});
950
+ return handle;
951
+ }
952
+ };
953
+ /** Read a Windows environment key using the platform's case-insensitive semantics. */
954
+ function environmentValue(env, name) {
955
+ const exact = env[name];
956
+ if (exact !== void 0 || process.platform !== "win32") return exact;
957
+ const normalized = name.toUpperCase();
958
+ return Object.entries(env).find(([key]) => key.toUpperCase() === normalized)?.[1];
959
+ }
960
+ //#endregion
961
+ export { LocalSubprocessService, LocalSubprocessService as default };