@deepseek-ai/dsh-subprocess-local 0.1.5-rc.2 → 0.1.6-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/lib/index.js CHANGED
@@ -1,13 +1,14 @@
1
- import { C as bindManagedProcess, D as validateSubprocessSpec, E as spawnSubprocess, O as createProcessInspector, S as loadLinuxExecve, T as prepareManagedProcessBinding, _ as parseWindowsRunnerResult, c as runnerStdio, d as cleanupLinuxLaunchFiles, l as spawnRunnerInvocation, m as deserializeRunnerError, n as WINDOWS_RUNNER_SELECTION, o as runnerEnvironment, p as createLinuxLaunchFiles, s as runnerInvocationAvailable, u as targetEnvironment, w as childEnv, y as readLinuxStartupError } from "./runner-launch-COYGu0Dl.js";
2
- import { closeSync, constants, existsSync, openSync } from "node:fs";
1
+ import { C as bindManagedProcess, D as createProcessInspector, E as validateSubprocessSpec, O as controlPipe, S as loadLinuxExecve, T as spawnSubprocess, _ as parseWindowsRunnerResult, c as runnerStdio, d as cleanupLinuxLaunchFiles, l as spawnRunnerInvocation, m as deserializeRunnerError, n as WINDOWS_RUNNER_SELECTION, o as runnerEnvironment, p as createLinuxLaunchFiles, s as runnerInvocationAvailable, u as targetEnvironment, w as childEnv, y as readLinuxStartupError } from "./runner-launch-DGV26RBf.js";
2
+ import { prepareManagedProcessBinding } from "./output.js";
3
+ import { closeSync, constants, existsSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
4
  import { access, stat } from "node:fs/promises";
4
- import { delimiter, extname, isAbsolute, resolve } from "node:path";
5
- import * as nodePty from "node-pty";
6
- import { SubprocessRuntime } from "@deepseek-ai/dsh-subprocess";
5
+ import { constants as constants$1, devNull, tmpdir, userInfo } from "node:os";
6
+ import { basename, delimiter, extname, isAbsolute, join, resolve } from "node:path";
7
+ import { createLazyRequire } from "@deepseek-ai/dsh-lazy-require";
8
+ import { SubprocessExecutableNotFoundError, SubprocessRuntime } from "@deepseek-ai/dsh-subprocess";
7
9
  import { execFile, spawn, spawnSync } from "node:child_process";
8
- import { randomBytes } from "node:crypto";
9
- import { constants as constants$1, devNull } from "node:os";
10
10
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
11
+ import { randomBytes } from "node:crypto";
11
12
  import { loadWin32ProcessBindings, probeCurrentTokenJobSupport } from "@deepseek-ai/dsh-win32-process";
12
13
  import { Buffer } from "node:buffer";
13
14
  import { PassThrough } from "node:stream";
@@ -118,9 +119,24 @@ function probeLinuxManager(internals = {}) {
118
119
  function probeLinuxNative(internals = {}) {
119
120
  return probeLinuxBootstrap(internals) && probeLinuxScope(internals);
120
121
  }
122
+ var LinuxScopeStartup = class {
123
+ files;
124
+ kind;
125
+ terminationSignals = /* @__PURE__ */ new Set();
126
+ constructor(files, kind) {
127
+ this.files = files;
128
+ this.kind = kind;
129
+ }
130
+ resolveOutcome(outcome) {
131
+ const startup = readLinuxStartupError(this.files.startupErrorPath);
132
+ if (startup !== void 0) throw deserializeRunnerError(startup.error);
133
+ if (existsSync(this.files.requestPath) && !(outcome.signal !== null && this.terminationSignals.has(outcome.signal))) throw new Error(`${this.kind} scope exited before its bootstrap consumed the launch request`);
134
+ return outcome;
135
+ }
136
+ };
121
137
  var SystemdScopeOwner = class {
122
138
  unit;
123
- files;
139
+ startup;
124
140
  direct;
125
141
  systemctl;
126
142
  runSync;
@@ -128,24 +144,46 @@ var SystemdScopeOwner = class {
128
144
  sleep;
129
145
  establishment = "pending";
130
146
  stopped = false;
147
+ terminationRequested = false;
131
148
  observation;
132
149
  killFailure;
150
+ directKillSettlement;
133
151
  wakeGeneration = 0;
134
152
  wakeWaiter;
135
- constructor(unit, files, direct, systemctl, runSync, query, sleep) {
153
+ constructor(unit, startup, direct, systemctl, runSync, query, sleep) {
136
154
  this.unit = unit;
137
- this.files = files;
155
+ this.startup = startup;
138
156
  this.direct = direct;
139
157
  this.systemctl = systemctl;
140
158
  this.runSync = runSync;
141
159
  this.query = query;
142
160
  this.sleep = sleep;
143
161
  }
162
+ inspectTaskCount() {
163
+ const result = this.runSync(this.systemctl, [
164
+ "--user",
165
+ "show",
166
+ "--property=LoadState",
167
+ "--property=ActiveState",
168
+ "--property=TasksCurrent",
169
+ this.unit
170
+ ], {
171
+ encoding: "utf8",
172
+ env: managerEnvironment(),
173
+ timeout: SYSTEMCTL_TIMEOUT_MS
174
+ });
175
+ if (result.error !== void 0 || result.status !== 0 || typeof result.stdout !== "string") return void 0;
176
+ const state = this.parseUnitState(result.stdout);
177
+ return state.loadState === "loaded" && state.activeState === "active" ? state.tasksCurrent : void 0;
178
+ }
144
179
  signal(signal) {
145
180
  if (this.stopped) return;
181
+ this.terminationRequested = true;
182
+ if (this.direct.running()) this.startup.terminationSignals.add(signal);
146
183
  this.observeRequestConsumption();
147
184
  const directFallbackRequired = this.establishment === "pending";
148
- if (directFallbackRequired && this.direct.running()) this.direct.signal(signal);
185
+ let directSignalled = false;
186
+ if (directFallbackRequired && this.direct.running()) directSignalled = this.direct.signal(signal);
149
187
  const result = this.runSync(this.systemctl, [
150
188
  "--user",
151
189
  "kill",
@@ -159,13 +197,19 @@ var SystemdScopeOwner = class {
159
197
  });
160
198
  this.wakeObservation();
161
199
  if (result.error === void 0 && result.status === 0) {
162
- if (signal === "SIGKILL") this.killFailure = void 0;
200
+ if (signal === "SIGKILL") {
201
+ this.killFailure = void 0;
202
+ this.directKillSettlement = void 0;
203
+ }
163
204
  return;
164
205
  }
165
- if (!directFallbackRequired && this.direct.running()) this.direct.signal(signal);
206
+ if (!directFallbackRequired && this.direct.running()) directSignalled = this.direct.signal(signal);
166
207
  if (signal === "SIGKILL") {
167
208
  const output = `${result.stdout}\n${result.stderr}`;
168
- if (!MISSING_UNIT.test(output)) this.killFailure = result.error ?? /* @__PURE__ */ new Error(`systemctl could not signal ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`);
209
+ if (!MISSING_UNIT.test(output)) {
210
+ this.killFailure = result.error ?? /* @__PURE__ */ new Error(`systemctl could not signal ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`);
211
+ this.directKillSettlement = directSignalled ? this.direct.settled.then(() => {}, () => {}) : void 0;
212
+ }
169
213
  }
170
214
  }
171
215
  terminateForHostExit() {
@@ -188,15 +232,40 @@ var SystemdScopeOwner = class {
188
232
  } catch {}
189
233
  }
190
234
  observeRequestConsumption() {
191
- if (this.establishment === "pending" && !existsSync(this.files.requestPath)) this.establishment = "established";
235
+ if (this.establishment === "pending" && !existsSync(this.startup.files.requestPath)) this.establishment = "established";
192
236
  }
193
237
  absentUnit() {
194
238
  this.observeRequestConsumption();
195
239
  if (this.establishment === "established") return false;
196
- if (!this.direct.running() && existsSync(this.files.requestPath)) return false;
240
+ if (!this.direct.running() && existsSync(this.startup.files.requestPath)) return false;
197
241
  if (this.killFailure !== void 0) throw this.killFailure;
198
242
  return true;
199
243
  }
244
+ /**
245
+ * Prove an active unit with no processes is the empty managed range rather
246
+ * than a launch still placing its payload. systemd ends a scope only on the
247
+ * populated-to-empty transition, so a payload killed before it entered the
248
+ * cgroup leaves the unit active forever. A departed client cannot add another
249
+ * payload; a consumed request proves the payload already entered the scope,
250
+ * even while its direct-process exit notification is pending.
251
+ */
252
+ emptyRange(tasksCurrent) {
253
+ return this.terminationRequested && tasksCurrent === 0 && (!this.direct.running() || !existsSync(this.startup.files.requestPath));
254
+ }
255
+ /** Release a leftover empty scope so the transient unit is collected and cannot accumulate. */
256
+ releaseEmptyRange() {
257
+ try {
258
+ this.runSync(this.systemctl, [
259
+ "--user",
260
+ "stop",
261
+ this.unit
262
+ ], {
263
+ env: managerEnvironment(),
264
+ stdio: "ignore",
265
+ timeout: SYSTEMCTL_TIMEOUT_MS
266
+ });
267
+ } catch {}
268
+ }
200
269
  parseUnitState(stdout) {
201
270
  const values = /* @__PURE__ */ new Map();
202
271
  for (const line of stdout.split(/\r?\n/u)) {
@@ -209,24 +278,32 @@ var SystemdScopeOwner = class {
209
278
  }
210
279
  const loadState = values.get("LoadState");
211
280
  const activeState = values.get("ActiveState");
212
- if (values.size !== 2 || loadState === void 0 || activeState === void 0) throw new Error(`systemctl returned incomplete state for ${this.unit}: ${JSON.stringify(stdout.trim())}`);
281
+ const reportedTasks = values.get("TasksCurrent");
282
+ const tasksCurrent = reportedTasks === "[not set]" ? void 0 : reportedTasks;
283
+ if (values.size !== (reportedTasks === void 0 ? 2 : 3) || loadState === void 0 || activeState === void 0) throw new Error(`systemctl returned incomplete state for ${this.unit}: ${JSON.stringify(stdout.trim())}`);
284
+ if (tasksCurrent !== void 0 && !/^\d+$/u.test(tasksCurrent)) throw new Error(`systemctl returned a non-numeric TasksCurrent for ${this.unit}: ${JSON.stringify(tasksCurrent)}`);
213
285
  return {
214
286
  loadState,
215
- activeState
287
+ activeState,
288
+ tasksCurrent: tasksCurrent === void 0 ? void 0 : Number(tasksCurrent)
216
289
  };
217
290
  }
218
291
  async rangeActive() {
219
292
  this.observeRequestConsumption();
293
+ const generation = this.wakeGeneration;
294
+ const directRunning = this.direct.running();
220
295
  const result = await this.query(this.systemctl, [
221
296
  "--user",
222
297
  "show",
223
298
  this.unit,
224
299
  "--property=LoadState",
225
- "--property=ActiveState"
300
+ "--property=ActiveState",
301
+ "--property=TasksCurrent"
226
302
  ]);
303
+ if (generation !== this.wakeGeneration) return true;
227
304
  const output = `${result.stdout}\n${result.stderr}`;
228
305
  if (result.status === 0) {
229
- const { loadState, activeState } = this.parseUnitState(result.stdout);
306
+ const { loadState, activeState, tasksCurrent } = this.parseUnitState(result.stdout);
230
307
  if (loadState === "not-found" && activeState === "inactive") return this.absentUnit();
231
308
  if (loadState !== "loaded") throw new Error(`systemctl returned unknown state for ${this.unit}: ${JSON.stringify({
232
309
  loadState,
@@ -240,7 +317,19 @@ var SystemdScopeOwner = class {
240
317
  "reloading",
241
318
  "deactivating"
242
319
  ].includes(activeState)) throw new Error(`systemctl returned unknown ActiveState for ${this.unit}: ${JSON.stringify(activeState)}`);
243
- if (this.killFailure !== void 0) throw this.killFailure;
320
+ if (this.emptyRange(tasksCurrent)) {
321
+ this.releaseEmptyRange();
322
+ return false;
323
+ }
324
+ if (this.killFailure !== void 0) {
325
+ if (directRunning && this.directKillSettlement !== void 0) {
326
+ const settlement = this.directKillSettlement;
327
+ this.directKillSettlement = void 0;
328
+ await settlement;
329
+ return this.rangeActive();
330
+ }
331
+ throw this.killFailure;
332
+ }
244
333
  return true;
245
334
  }
246
335
  if (!MISSING_UNIT.test(output)) {
@@ -288,7 +377,7 @@ var SystemdScopeOwner = class {
288
377
  await this.observation;
289
378
  }
290
379
  cleanup() {
291
- cleanupLinuxLaunchFiles(this.files);
380
+ cleanupLinuxLaunchFiles(this.startup.files);
292
381
  }
293
382
  };
294
383
  function scopeArgs(unitBase, invocation, argv) {
@@ -305,7 +394,7 @@ function scopeArgs(unitBase, invocation, argv) {
305
394
  ...argv
306
395
  ];
307
396
  }
308
- function directOutcome(child, files) {
397
+ function directOutcome(child, startup) {
309
398
  return new Promise((resolveOutcome, rejectOutcome) => {
310
399
  let settled = false;
311
400
  child.once("error", (error) => {
@@ -317,40 +406,47 @@ function directOutcome(child, files) {
317
406
  if (settled) return;
318
407
  settled = true;
319
408
  try {
320
- const startup = readLinuxStartupError(files.startupErrorPath);
321
- if (startup !== void 0) {
322
- rejectOutcome(deserializeRunnerError(startup.error));
323
- return;
324
- }
325
- if (existsSync(files.requestPath)) {
326
- rejectOutcome(/* @__PURE__ */ new Error("subprocess scope exited before its bootstrap consumed the launch request"));
327
- return;
328
- }
329
- resolveOutcome({
409
+ resolveOutcome(startup.resolveOutcome({
330
410
  exitCode,
331
411
  signal
332
- });
412
+ }));
333
413
  } catch (error) {
334
414
  rejectOutcome(error instanceof Error ? error : new Error(String(error)));
335
415
  }
336
416
  });
337
417
  });
338
418
  }
339
- function signalChildGroup(child, signal) {
419
+ /**
420
+ * Send a direct-process signal, distinguishing an absent PID from failed delivery.
421
+ * @param pid - owned direct-process identity whose exit notification can still be pending.
422
+ * @param send - platform signal operation; true means the signal was submitted.
423
+ * @returns whether the signal was submitted or the owned PID is already absent.
424
+ */
425
+ function signalLinuxDirectProcess(pid, send) {
340
426
  try {
341
- process.kill(-child.pid, signal);
342
- } catch {
343
- try {
344
- child.kill(signal);
345
- } catch {}
427
+ if (send()) return true;
428
+ } catch {}
429
+ try {
430
+ process.kill(pid, 0);
431
+ return false;
432
+ } catch (error) {
433
+ return error.code === "ESRCH";
346
434
  }
347
435
  }
436
+ function signalChildGroup(child, signal) {
437
+ let groupSignalled = false;
438
+ try {
439
+ groupSignalled = process.kill(-child.pid, signal);
440
+ } catch {}
441
+ if (groupSignalled && signal === "SIGTERM") return true;
442
+ return signalLinuxDirectProcess(child.pid, () => process.kill(child.pid, signal));
443
+ }
348
444
  /**
349
445
  * Prepare one Linux PTY scope using the same launch request and bootstrap core.
350
446
  * @param spec - terminal target request.
351
447
  * @param targetEnv - validated complete target environment.
352
448
  * @param internals - optional runner and systemd seams used by tests.
353
- * @returns invocation facts and ownership callbacks for node-pty.
449
+ * @returns invocation and ownership callbacks; requested termination preserves the observed signal even before bootstrap consumption.
354
450
  */
355
451
  function prepareLinuxTerminalScope(spec, targetEnv, internals = {}) {
356
452
  const invocation = internals.runnerInvocation ?? spawnRunnerInvocation();
@@ -358,19 +454,15 @@ function prepareLinuxTerminalScope(spec, targetEnv, internals = {}) {
358
454
  cwd: spec.cwd,
359
455
  env: targetEnv
360
456
  });
457
+ const startup = new LinuxScopeStartup(files, "terminal");
361
458
  const unitBase = unitStem("dsh-terminal");
362
459
  return {
363
460
  command: internals.systemdRun ?? "systemd-run",
364
461
  args: scopeArgs(unitBase, invocation, spec.argv),
365
462
  cwd: process.cwd(),
366
463
  env: runnerEnvironment(files.requestPath, invocation),
367
- bindOwner: (direct) => new SystemdScopeOwner(`${unitBase}.scope`, files, direct, internals.systemctl ?? "systemctl", internals.spawnSync ?? spawnSync, internals.systemctlQuery ?? querySystemctl, internals.sleep ?? sleepWithAbort),
368
- resolveOutcome: (outcome) => {
369
- const startup = readLinuxStartupError(files.startupErrorPath);
370
- if (startup !== void 0) throw deserializeRunnerError(startup.error);
371
- if (existsSync(files.requestPath)) throw new Error("terminal scope exited before its bootstrap consumed the launch request");
372
- return outcome;
373
- },
464
+ bindOwner: (direct) => new SystemdScopeOwner(`${unitBase}.scope`, startup, direct, internals.systemctl ?? "systemctl", internals.spawnSync ?? spawnSync, internals.systemctlQuery ?? querySystemctl, internals.sleep ?? sleepWithAbort),
465
+ resolveOutcome: (outcome) => startup.resolveOutcome(outcome),
374
466
  cleanup: () => {
375
467
  cleanupLinuxLaunchFiles(files);
376
468
  }
@@ -381,14 +473,16 @@ function prepareLinuxTerminalScope(spec, targetEnv, internals = {}) {
381
473
  * @param spec - ordinary target request.
382
474
  * @param targetEnv - validated complete target environment.
383
475
  * @param internals - optional runner and systemd seams used by tests.
384
- * @returns direct streams, result, and managed-scope owner.
476
+ * @returns streams, result, and scope owner; requested termination preserves the observed signal even before bootstrap consumption.
385
477
  */
386
478
  function launchLinuxScope(spec, targetEnv, internals = {}) {
387
479
  const invocation = internals.runnerInvocation ?? spawnRunnerInvocation();
388
480
  const files = createLinuxLaunchFiles({
389
481
  cwd: spec.cwd,
390
- env: targetEnv
482
+ env: targetEnv,
483
+ ...spec.stdio.control === void 0 ? {} : { control: spec.stdio.control }
391
484
  });
485
+ const startup = new LinuxScopeStartup(files, "subprocess");
392
486
  const unitBase = unitStem("dsh-subprocess");
393
487
  let child;
394
488
  try {
@@ -402,17 +496,18 @@ function launchLinuxScope(spec, targetEnv, internals = {}) {
402
496
  cleanupLinuxLaunchFiles(files);
403
497
  throw error;
404
498
  }
405
- const owner = new SystemdScopeOwner(`${unitBase}.scope`, files, {
499
+ const direct = directOutcome(child, startup);
500
+ const owner = new SystemdScopeOwner(`${unitBase}.scope`, startup, {
406
501
  running: () => child.pid !== void 0 && child.exitCode === null && child.signalCode === null,
407
- signal: (signal) => {
408
- signalChildGroup(child, signal);
409
- }
502
+ signal: (signal) => signalChildGroup(child, signal),
503
+ settled: direct
410
504
  }, internals.systemctl ?? "systemctl", internals.spawnSync ?? spawnSync, internals.systemctlQuery ?? querySystemctl, internals.sleep ?? sleepWithAbort);
411
505
  return {
412
506
  stdin: child.stdin,
413
507
  stdout: child.stdout,
414
508
  stderr: child.stderr,
415
- direct: directOutcome(child, files),
509
+ control: controlPipe(child, spec.stdio.control),
510
+ direct,
416
511
  owner
417
512
  };
418
513
  }
@@ -502,6 +597,7 @@ function launchWindowsJob(spec, targetEnv, internals = {}) {
502
597
  ...spec.argv
503
598
  ], {
504
599
  cwd: process.cwd(),
600
+ windowsHide: true,
505
601
  env: runnerEnvironment(WINDOWS_RUNNER_SELECTION, invocation),
506
602
  stdio: runnerStdio(spec, true, ignoredStdinFd ?? "pipe")
507
603
  });
@@ -513,10 +609,25 @@ function launchWindowsJob(spec, targetEnv, internals = {}) {
513
609
  const rangeExit = Promise.withResolvers();
514
610
  let directResultType;
515
611
  let runnerSpawned = false;
612
+ let runnerExit;
613
+ let ipcDisconnected = false;
516
614
  const failInfrastructure = (error) => {
517
615
  direct.reject(error);
518
616
  rangeExit.reject(error);
519
617
  };
618
+ const settleRange = () => {
619
+ if (!runnerSpawned || runnerExit === void 0) return;
620
+ const { exitCode, signal } = runnerExit;
621
+ if (exitCode === 0 && signal === null) {
622
+ if (directResultType !== void 0) {
623
+ rangeExit.resolve();
624
+ return;
625
+ }
626
+ if (!ipcDisconnected) return;
627
+ }
628
+ const status = signal !== null ? `signal ${signal}` : exitCode === null ? "without an exit status" : `exit code ${String(exitCode)}`;
629
+ failInfrastructure(/* @__PURE__ */ new Error(`subprocess-local: Windows Job runner exited with ${status} before proving its managed range empty`));
630
+ };
520
631
  const owner = new WindowsJobOwner(child, rangeExit.promise, () => directResultType, failInfrastructure);
521
632
  child.on("message", (value) => {
522
633
  if (directResultType !== void 0) {
@@ -538,6 +649,7 @@ function launchWindowsJob(spec, targetEnv, internals = {}) {
538
649
  signal: null
539
650
  });
540
651
  else direct.reject(owner.mapStartFailure(deserializeRunnerError(result.error), result.error));
652
+ settleRange();
541
653
  });
542
654
  child.once("spawn", () => {
543
655
  runnerSpawned = true;
@@ -546,7 +658,8 @@ function launchWindowsJob(spec, targetEnv, internals = {}) {
546
658
  child.send({
547
659
  type: "start",
548
660
  cwd: spec.cwd,
549
- env: targetEnv
661
+ env: targetEnv,
662
+ ...spec.stdio.control === void 0 ? {} : { control: spec.stdio.control }
550
663
  }, (error) => {
551
664
  if (error === null) return;
552
665
  failInfrastructure(error);
@@ -565,19 +678,22 @@ function launchWindowsJob(spec, targetEnv, internals = {}) {
565
678
  }
566
679
  failInfrastructure(error);
567
680
  });
568
- child.once("close", (exitCode, signal) => {
569
- if (!runnerSpawned) return;
570
- if (exitCode === 0 && signal === null && directResultType !== void 0) {
571
- rangeExit.resolve();
572
- return;
573
- }
574
- const status = signal !== null ? `signal ${signal}` : exitCode === null ? "without an exit status" : `exit code ${String(exitCode)}`;
575
- failInfrastructure(/* @__PURE__ */ new Error(`subprocess-local: Windows Job runner exited with ${status} before proving its managed range empty`));
681
+ child.once("exit", (exitCode, signal) => {
682
+ runnerExit = {
683
+ exitCode,
684
+ signal
685
+ };
686
+ settleRange();
687
+ });
688
+ child.once("disconnect", () => {
689
+ ipcDisconnected = true;
690
+ settleRange();
576
691
  });
577
692
  return {
578
693
  stdin: spec.stdio.stdin === "ignore" ? null : targetStdin,
579
694
  stdout: child.stdio[5],
580
695
  stderr: child.stdio[6],
696
+ control: controlPipe(child, spec.stdio.control),
581
697
  direct: direct.promise,
582
698
  owner
583
699
  };
@@ -625,6 +741,9 @@ var LocalTerminalHandle = class {
625
741
  platform;
626
742
  managedOwner;
627
743
  resolveManagedOutcome;
744
+ shellActivity;
745
+ onQuiescence;
746
+ observeShellExit;
628
747
  pid;
629
748
  output = new PassThrough();
630
749
  done;
@@ -634,7 +753,12 @@ var LocalTerminalHandle = class {
634
753
  cleanup;
635
754
  managedOwnerCleaned = false;
636
755
  exited = false;
756
+ outputPaused = false;
637
757
  trackedDescendants = [];
758
+ activityRevision = 0;
759
+ activityKey = "";
760
+ quiescent = false;
761
+ managedRangeEmpty = false;
638
762
  /** The spawned shell's start identity; scans stop adopting members once the root pid no longer carries it. */
639
763
  rootIdentity;
640
764
  /**
@@ -643,22 +767,44 @@ var LocalTerminalHandle = class {
643
767
  * @param graceMs - TERM-to-KILL and exit-wait grace.
644
768
  * @param platform - host platform; defaults to the running platform, injectable for deterministic tests.
645
769
  */
646
- constructor(terminal, inspector, graceMs, platform = process.platform, managedOwner, resolveManagedOutcome) {
770
+ constructor(terminal, inspector, graceMs, platform = process.platform, managedOwner, resolveManagedOutcome, shellActivity, onQuiescence, observeShellExit = false) {
647
771
  this.terminal = terminal;
648
772
  this.inspector = inspector;
649
773
  this.graceMs = graceMs;
650
774
  this.platform = platform;
651
775
  this.managedOwner = managedOwner;
652
776
  this.resolveManagedOutcome = resolveManagedOutcome;
777
+ this.shellActivity = shellActivity;
778
+ this.onQuiescence = onQuiescence;
779
+ this.observeShellExit = observeShellExit;
653
780
  this.pid = terminal.pid;
654
- this.rootIdentity = inspector.snapshot().tree(this.pid).find((member) => member.pid === this.pid);
781
+ try {
782
+ this.rootIdentity = inspector.snapshot().tree(this.pid).find((member) => member.pid === this.pid);
783
+ } catch (_rootIdentityUnavailable) {
784
+ this.rootIdentity = void 0;
785
+ }
655
786
  this.done = this.outcome.promise;
787
+ const resume = () => {
788
+ if (!this.outputPaused) return;
789
+ this.outputPaused = false;
790
+ if (!this.exited) terminal.resume();
791
+ };
792
+ this.output.on("drain", resume);
793
+ this.output.once("close", () => {
794
+ this.output.off("drain", resume);
795
+ });
656
796
  this.dataDisposable = terminal.onData((data) => {
657
- this.output.write(Buffer.from(data, "utf8"));
797
+ if (!this.output.write(Buffer.from(data, "utf8")) && this.cleanup === void 0 && !this.outputPaused) {
798
+ this.outputPaused = true;
799
+ terminal.pause();
800
+ }
658
801
  });
659
802
  this.exitDisposable = terminal.onExit(({ exitCode, signal: exitSignal }) => {
660
803
  if (this.exited) return;
661
804
  this.exited = true;
805
+ if (this.managedOwner !== void 0 && this.observeShellExit) this.managedOwner.waitForExit().then(() => {
806
+ this.managedRangeEmpty = true;
807
+ }).catch(() => {});
662
808
  this.output.end();
663
809
  const outcome = {
664
810
  exitCode: exitSignal === void 0 || exitSignal === 0 ? exitCode : null,
@@ -677,8 +823,13 @@ var LocalTerminalHandle = class {
677
823
  }
678
824
  async write(data) {
679
825
  if (this.exited) throw new Error("terminal process has exited");
826
+ this.shellActivity?.invalidate();
680
827
  this.terminal.write(data);
681
828
  }
829
+ async resize(cols, rows) {
830
+ if (this.exited) throw new Error("terminal process has exited");
831
+ this.terminal.resize(cols, rows);
832
+ }
682
833
  async inspectForeground() {
683
834
  this.descendants(this.inspector.snapshot());
684
835
  const processGroupId = this.inspector.foregroundPgid(this.pid);
@@ -688,7 +839,44 @@ var LocalTerminalHandle = class {
688
839
  inputWaiting: this.inspector.isStdinWaiting(processGroupId, this.pid)
689
840
  };
690
841
  }
842
+ async inspectActivity() {
843
+ let state = this.quiescent ? "idle" : "unknown";
844
+ let revision = 0;
845
+ if (!this.quiescent) try {
846
+ const shell = this.shellActivity?.inspect(this.pid) ?? {
847
+ state: "unknown",
848
+ revision: 0
849
+ };
850
+ revision = shell.revision;
851
+ const observed = this.inspector.snapshot();
852
+ const descendants = this.descendants(observed);
853
+ const root = observed.tree(this.pid).find((member) => member.pid === this.pid);
854
+ if (this.exited && this.managedRangeEmpty) state = "idle";
855
+ else if (descendants.length > 0) state = "busy";
856
+ else if (this.exited && this.managedOwner === void 0 && this.platform === "linux" && observed.complete === true && root === void 0) state = observed.session(this.pid).some((member) => observed.alive(member)) ? "busy" : "idle";
857
+ else if (observed.complete === true && root?.started === this.rootIdentity?.started && root !== void 0) {
858
+ const foreground = this.inspector.foregroundPgid(this.pid);
859
+ state = foreground === void 0 ? "unknown" : foreground === this.pid ? shell.state : "busy";
860
+ if (state === "idle" && this.managedOwner !== void 0) {
861
+ const tasks = this.managedOwner.inspectTaskCount?.();
862
+ state = tasks === void 0 || tasks < 1 ? "unknown" : tasks === 1 ? "idle" : "busy";
863
+ }
864
+ }
865
+ } catch (_incompleteActivityObservation) {
866
+ state = "unknown";
867
+ }
868
+ const key = `${revision}:${state}`;
869
+ if (key !== this.activityKey) {
870
+ this.activityKey = key;
871
+ this.activityRevision++;
872
+ }
873
+ return {
874
+ state,
875
+ revision: this.activityRevision
876
+ };
877
+ }
691
878
  async signalForeground(signal) {
879
+ this.shellActivity?.invalidate();
692
880
  const foreground = await this.inspectForeground();
693
881
  if (foreground === void 0) throw new Error(`cannot resolve foreground process group for terminal ${this.pid}`);
694
882
  if (signal === "SIGKILL" && foreground.processGroupId === this.pid) throw new Error("refusing to SIGKILL the terminal shell; terminate the terminal session instead");
@@ -704,7 +892,15 @@ var LocalTerminalHandle = class {
704
892
  }
705
893
  terminate() {
706
894
  if (this.cleanup !== void 0) return this.cleanup;
707
- const cleanup = this.closeOnce();
895
+ if (this.outputPaused) {
896
+ this.outputPaused = false;
897
+ this.terminal.resume();
898
+ }
899
+ const cleanup = this.closeOnce().then(() => {
900
+ this.quiescent = true;
901
+ this.shellActivity?.dispose();
902
+ this.onQuiescence?.();
903
+ });
708
904
  this.cleanup = cleanup;
709
905
  cleanup.catch(() => {
710
906
  this.cleanup = void 0;
@@ -894,6 +1090,164 @@ var LocalTerminalHandle = class {
894
1090
  }
895
1091
  };
896
1092
  //#endregion
1093
+ //#region lib/types/shell-activity.js
1094
+ /** Private shell lifecycle files; ordinary output never authorizes terminal reclamation. */
1095
+ function quote(value) {
1096
+ return `'${value.replaceAll("'", "'\\''")}'`;
1097
+ }
1098
+ /** Opt-in startup integration and revision tracking for one ordinary interactive shell. */
1099
+ var ShellActivity = class {
1100
+ directory;
1101
+ argv;
1102
+ env;
1103
+ revision = 0;
1104
+ observed = "";
1105
+ invalidated;
1106
+ state = "unknown";
1107
+ /**
1108
+ * @param directory - private startup and status file directory.
1109
+ * @param argv - shell launch preserving supported user startup files.
1110
+ * @param env - environment with any temporary startup redirect.
1111
+ */
1112
+ constructor(directory, argv, env) {
1113
+ this.directory = directory;
1114
+ this.argv = argv;
1115
+ this.env = env;
1116
+ }
1117
+ /** Invalidate prompt evidence before delivering input or a foreground signal. */
1118
+ invalidate() {
1119
+ this.invalidated = this.read();
1120
+ this.revision++;
1121
+ this.state = "unknown";
1122
+ }
1123
+ /**
1124
+ * Read the latest top-level shell transition, fenced against input since that transition.
1125
+ * @param pid - original shell process id.
1126
+ * @returns lifecycle evidence; process ownership must be checked separately.
1127
+ */
1128
+ inspect(pid) {
1129
+ const record = this.read();
1130
+ if (record !== this.observed) {
1131
+ this.observed = record;
1132
+ this.revision++;
1133
+ }
1134
+ const match = /^(\d+):(\d+):(idle|busy)\n?$/u.exec(record);
1135
+ this.state = record === this.invalidated || match?.[1] !== String(pid) ? "unknown" : match[3];
1136
+ return {
1137
+ state: this.state,
1138
+ revision: this.revision
1139
+ };
1140
+ }
1141
+ /** Remove private startup and status files after process quiescence. */
1142
+ dispose() {
1143
+ rmSync(this.directory, {
1144
+ recursive: true,
1145
+ force: true
1146
+ });
1147
+ }
1148
+ read() {
1149
+ try {
1150
+ return readFileSync(join(this.directory, "state"), "utf8");
1151
+ } catch (_unavailableShellObservation) {
1152
+ return "";
1153
+ }
1154
+ }
1155
+ };
1156
+ /**
1157
+ * Prepare optional Bash or Zsh integration for a plain, non-login interactive launch.
1158
+ * @param spec - terminal request; custom arguments and wrapped executables remain unmodified.
1159
+ * @param env - scrubbed target environment.
1160
+ * @param platform - execution platform.
1161
+ * @returns private integration, or undefined for unsupported launches.
1162
+ */
1163
+ function prepareShellActivity(spec, env, platform) {
1164
+ if (spec.shellActivity !== true || platform === "win32" || spec.argv.length !== 2 || spec.argv[1] !== "-i") return void 0;
1165
+ const shell = basename(spec.argv[0]);
1166
+ if (shell !== "bash" && shell !== "zsh") return void 0;
1167
+ const directory = mkdtempSync(join(tmpdir(), "dsh-shell-"));
1168
+ const state = quote(join(directory, "state"));
1169
+ const guards = quote(join(directory, "guards"));
1170
+ try {
1171
+ if (shell === "bash") {
1172
+ const rc = join(directory, "bashrc");
1173
+ writeFileSync(rc, [
1174
+ "[[ ! -r ~/.bashrc ]] || builtin source ~/.bashrc",
1175
+ "if (( BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4) )) && [[ ! $(declare -p PROMPT_COMMAND PS0 2>/dev/null) =~ declare\\ -[^[:space:]]*r ]]; then",
1176
+ " __dsh_shell_pid=$BASHPID; __dsh_shell_sequence=0",
1177
+ " __dsh_shell_idle() {",
1178
+ " local result=$?",
1179
+ " if [[ $BASHPID == \"$__dsh_shell_pid\" ]]; then",
1180
+ " (( ++__dsh_shell_sequence ))",
1181
+ " local activity=idle",
1182
+ ` builtin trap -p >| ${guards}`,
1183
+ ` [[ ! -s ${guards} ]] || activity=unknown`,
1184
+ ` builtin printf '%s:%s:%s\\n' "$BASHPID" "$__dsh_shell_sequence" "$activity" >| ${state}`,
1185
+ " fi",
1186
+ " return \"$result\"",
1187
+ " }",
1188
+ " if [[ $(declare -p PROMPT_COMMAND 2>/dev/null) == \"declare -a \"* ]]; then",
1189
+ " PROMPT_COMMAND+=(__dsh_shell_idle)",
1190
+ " else",
1191
+ " PROMPT_COMMAND=\"${PROMPT_COMMAND}\"$'\\n'\"__dsh_shell_idle\"",
1192
+ " fi",
1193
+ ` PS0+=${quote(`$(builtin printf '%s:%s:busy' "$__dsh_shell_pid" "$__dsh_shell_sequence" >| ${state})`)}`,
1194
+ "fi",
1195
+ ""
1196
+ ].join("\n"), {
1197
+ mode: 384,
1198
+ flag: "wx"
1199
+ });
1200
+ return new ShellActivity(directory, [
1201
+ spec.argv[0],
1202
+ "--rcfile",
1203
+ rc,
1204
+ "-i"
1205
+ ], env);
1206
+ }
1207
+ writeFileSync(join(directory, ".zshenv"), [
1208
+ env.ZDOTDIR === void 0 ? "unset ZDOTDIR" : `ZDOTDIR=${quote(env.ZDOTDIR)}`,
1209
+ "[[ ! -r ${ZDOTDIR:-$HOME}/.zshenv ]] || builtin source \"${ZDOTDIR:-$HOME}/.zshenv\"",
1210
+ "typeset -g __dsh_shell_pid=$$ __dsh_shell_sequence=0",
1211
+ "__dsh_shell_activity() {",
1212
+ " (( ZSH_SUBSHELL == 0 && $$ == __dsh_shell_pid )) || return",
1213
+ " (( ++__dsh_shell_sequence ))",
1214
+ ` builtin printf '%s:%s:%s\\n' "$$" "$__dsh_shell_sequence" "$1" >| ${state}`,
1215
+ " return 0",
1216
+ "}",
1217
+ "__dsh_shell_idle() {",
1218
+ ` { builtin trap; zle -F; } >| ${guards}`,
1219
+ " if [[ $CONTEXT != start || -n $BUFFER ]]; then __dsh_shell_activity busy",
1220
+ ` elif [[ -s ${guards} || -n \${(k)functions[(I)TRAP*]} ]]; then __dsh_shell_activity unknown`,
1221
+ " else __dsh_shell_activity idle; fi",
1222
+ "}",
1223
+ "__dsh_shell_busy() { __dsh_shell_activity busy }",
1224
+ "__dsh_shell_init() {",
1225
+ " autoload -Uz add-zle-hook-widget add-zsh-hook",
1226
+ " add-zle-hook-widget line-init __dsh_shell_idle",
1227
+ " add-zle-hook-widget line-finish __dsh_shell_busy",
1228
+ " add-zsh-hook preexec __dsh_shell_busy",
1229
+ " precmd_functions=(${precmd_functions:#__dsh_shell_init})",
1230
+ "}",
1231
+ "typeset -ga precmd_functions",
1232
+ "precmd_functions+=(__dsh_shell_init)",
1233
+ ""
1234
+ ].join("\n"), {
1235
+ mode: 384,
1236
+ flag: "wx"
1237
+ });
1238
+ return new ShellActivity(directory, spec.argv, {
1239
+ ...env,
1240
+ ZDOTDIR: directory
1241
+ });
1242
+ } catch (error) {
1243
+ rmSync(directory, {
1244
+ recursive: true,
1245
+ force: true
1246
+ });
1247
+ throw error;
1248
+ }
1249
+ }
1250
+ //#endregion
897
1251
  //#region lib/types/index.js
898
1252
  /**
899
1253
  * Local Service Provider for the subprocess capability seam. Each spawn owns a
@@ -904,6 +1258,7 @@ var LocalTerminalHandle = class {
904
1258
  * stay with the caller's config (the bash executor's, the LSP host's, …).
905
1259
  * @module @deepseek-ai/dsh-subprocess-local
906
1260
  */
1261
+ const requireNodePty = createLazyRequire("node-pty", import.meta.url);
907
1262
  /**
908
1263
  * Local subprocess service: platform-selected managed ranges, Node-shaped stdio
909
1264
  * dispositions (raw pipes, inherit, bounded tail-keep collection with spill
@@ -916,6 +1271,8 @@ var LocalSubprocessRuntime = class extends SubprocessRuntime {
916
1271
  live = /* @__PURE__ */ new Set();
917
1272
  /** Live terminals retained through normal quiescence or host-exit finalization. */
918
1273
  terminals = /* @__PURE__ */ new Set();
1274
+ /** Caller endpoints retained until close, independently of managed process lifetime. */
1275
+ controlChannels = /* @__PURE__ */ new Set();
919
1276
  /** Test hook: process, spill, and platform operations forwarded to spawnSubprocess. */
920
1277
  internals = {};
921
1278
  /** Provider-lifetime latch suppressing repeated weaker-containment warnings. */
@@ -957,6 +1314,13 @@ var LocalSubprocessRuntime = class extends SubprocessRuntime {
957
1314
  this.terminals.delete(terminal);
958
1315
  }));
959
1316
  const outcomes = await Promise.allSettled(pending);
1317
+ await Promise.all([...this.controlChannels].map((control) => new Promise((resolveClose) => {
1318
+ control.once("close", () => {
1319
+ resolveClose();
1320
+ });
1321
+ control.destroy();
1322
+ })));
1323
+ this.controlChannels.clear();
960
1324
  const failures = [];
961
1325
  for (const outcome of outcomes) if (outcome.status === "rejected") failures.push(outcome.reason);
962
1326
  if (failures.length > 0) this.terminateForHostExit();
@@ -980,7 +1344,7 @@ var LocalSubprocessRuntime = class extends SubprocessRuntime {
980
1344
  } catch {}
981
1345
  }
982
1346
  signal?.throwIfAborted();
983
- 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`);
1347
+ throw new SubprocessExecutableNotFoundError(absolute ? `subprocess-local: command ${JSON.stringify(command)} is not an executable file` : `subprocess-local: command ${JSON.stringify(command)} was not found on PATH`);
984
1348
  }
985
1349
  executableCandidates(command, env) {
986
1350
  const path = environmentValue(env, "PATH") ?? "";
@@ -998,6 +1362,13 @@ var LocalSubprocessRuntime = class extends SubprocessRuntime {
998
1362
  handle = bindManagedProcess(spec, containmentMode === "linux-scope" ? launchLinuxScope(spec, env) : launchWindowsJob(spec, env), binding);
999
1363
  }
1000
1364
  this.live.add(handle);
1365
+ const control = handle.control;
1366
+ if (control !== void 0) {
1367
+ this.controlChannels.add(control);
1368
+ control.once("close", () => {
1369
+ this.controlChannels.delete(control);
1370
+ });
1371
+ }
1001
1372
  const release = () => handle.waitForExit().then(() => {
1002
1373
  this.live.delete(handle);
1003
1374
  });
@@ -1025,47 +1396,71 @@ var LocalSubprocessRuntime = class extends SubprocessRuntime {
1025
1396
  const reason = selectedReason ?? (platform === "darwin" ? "macOS has no supported persistent process-range owner" : platform === "win32" ? kind === "terminal" ? "Windows ConPTY remains outside Job containment" : "the Win32 Job runner is unavailable" : `platform ${platform} has no native managed range`);
1026
1397
  this.ctx.logger.warn(`subprocess-local is using weaker process-tree containment because ${reason}; descendants that escape the process group or direct-parent tree are not guaranteed to terminate or delay waitForExit()`);
1027
1398
  }
1399
+ /** @inheritdoc */
1400
+ async terminalEnvironment(signal) {
1401
+ signal?.throwIfAborted();
1402
+ const platform = process.platform === "win32" ? "windows" : "posix";
1403
+ const defaultShell = platform === "windows" ? process.env.ComSpec || void 0 : process.env.SHELL || userInfo().shell || void 0;
1404
+ return {
1405
+ platform,
1406
+ ...defaultShell === void 0 ? {} : { defaultShell }
1407
+ };
1408
+ }
1028
1409
  async spawnTerminal(spec) {
1029
1410
  const file = spec.argv[0];
1030
1411
  if (file === void 0 || file.length === 0) throw new Error("subprocess-local: terminal argv must contain a program");
1031
1412
  spec.signal?.throwIfAborted();
1413
+ const inspector = this.terminalInspector ?? createProcessInspector();
1414
+ const containmentMode = this.selectContainmentMode("terminal");
1032
1415
  const env = targetEnvironment(spec);
1416
+ const activity = prepareShellActivity(spec, env, this.internals.platform ?? process.platform);
1417
+ const launch = activity === void 0 ? spec : {
1418
+ ...spec,
1419
+ argv: activity.argv,
1420
+ env: activity.env
1421
+ };
1033
1422
  const options = {
1034
- name: "dumb",
1423
+ name: spec.terminalType,
1035
1424
  rows: spec.rows,
1036
1425
  cols: spec.cols,
1037
1426
  cwd: spec.cwd,
1038
- env
1427
+ env: {
1428
+ ...activity?.env ?? env,
1429
+ TERM: spec.terminalType
1430
+ }
1039
1431
  };
1040
- const inspector = this.terminalInspector ?? createProcessInspector();
1041
- const scope = this.selectContainmentMode("terminal") === "linux-scope" ? prepareLinuxTerminalScope(spec, {
1042
- ...env,
1043
- PWD: spec.cwd,
1044
- TERM: "dumb"
1045
- }) : void 0;
1046
- if (scope !== void 0) {
1047
- options.cwd = scope.cwd;
1048
- options.env = scope.env;
1049
- }
1432
+ let scope;
1050
1433
  let terminal;
1051
1434
  try {
1052
- terminal = nodePty.spawn(scope?.command ?? file, scope?.args ?? [...spec.argv.slice(1)], options);
1435
+ scope = containmentMode === "linux-scope" ? prepareLinuxTerminalScope(launch, {
1436
+ ...activity?.env ?? env,
1437
+ PWD: spec.cwd,
1438
+ TERM: spec.terminalType
1439
+ }) : void 0;
1440
+ if (scope !== void 0) {
1441
+ options.cwd = scope.cwd;
1442
+ options.env = scope.env;
1443
+ }
1444
+ terminal = requireNodePty().spawn(scope?.command ?? file, scope?.args ?? [...launch.argv.slice(1)], options);
1053
1445
  } catch (error) {
1054
1446
  scope?.cleanup();
1447
+ activity?.dispose();
1055
1448
  throw error;
1056
1449
  }
1057
1450
  let handle;
1451
+ const directSettlement = Promise.withResolvers();
1058
1452
  const owner = scope?.bindOwner({
1059
1453
  running: () => handle?.running ?? true,
1060
- signal: (signal) => {
1061
- try {
1062
- terminal.kill(signal);
1063
- } catch {}
1064
- }
1454
+ settled: directSettlement.promise,
1455
+ signal: (signal) => signalLinuxDirectProcess(terminal.pid, () => process.kill(terminal.pid, signal))
1065
1456
  });
1066
- handle = new LocalTerminalHandle(terminal, inspector, spec.graceMs, this.internals.platform ?? process.platform, owner, scope?.resolveOutcome);
1457
+ handle = new LocalTerminalHandle(terminal, inspector, spec.graceMs, this.internals.platform ?? process.platform, owner, scope?.resolveOutcome, activity, () => {
1458
+ this.terminals.delete(handle);
1459
+ }, spec.shellActivity === true);
1067
1460
  this.terminals.add(handle);
1068
1461
  const release = async () => {
1462
+ directSettlement.resolve();
1463
+ if (spec.shellActivity === true) return;
1069
1464
  await handle.terminate();
1070
1465
  this.terminals.delete(handle);
1071
1466
  };