@mono-agent/agent-runtime 0.19.1 → 0.20.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.
Files changed (51) hide show
  1. package/MIGRATION.md +1 -1
  2. package/README.md +101 -4
  3. package/package.json +1 -1
  4. package/src/agent/sandbox-seam.js +16 -2
  5. package/src/agent/tools/bash.js +26 -4
  6. package/src/agent/tools/edit.js +72 -5
  7. package/src/agent/tools/exec.js +22 -4
  8. package/src/agent/tools/glob.js +65 -9
  9. package/src/agent/tools/grep.js +66 -11
  10. package/src/agent/tools/node-repl.js +5 -2
  11. package/src/agent/tools/pi-bridge.js +263 -48
  12. package/src/agent/tools/read.js +50 -10
  13. package/src/agent/tools/shared/path-resolver.js +67 -2
  14. package/src/agent/tools/shared/process-jobs.js +188 -0
  15. package/src/agent/tools/shared/process-runner.js +541 -30
  16. package/src/agent/tools/shared/protected-filesystem.js +150 -0
  17. package/src/agent/tools/web-search.js +63 -8
  18. package/src/agent/tools/write.js +52 -6
  19. package/src/ai/providers/acp.js +4 -0
  20. package/src/ai/providers/claude-cli.js +35 -2
  21. package/src/ai/providers/claude-sdk.js +12 -0
  22. package/src/ai/providers/codex-app.js +15 -2
  23. package/src/ai/providers/pi-native/stream-subscriber.js +29 -2
  24. package/src/ai/providers/pi-native/turn-runner.js +3 -0
  25. package/src/ai/providers/pi-native.js +7 -1
  26. package/src/ai/runtime/capabilities.js +2 -0
  27. package/src/ai/runtime/router.js +78 -6
  28. package/src/ai/streaming/codex-events.js +15 -0
  29. package/src/ai/streaming/opencode-events.js +5 -0
  30. package/src/ai/tool-lifecycle.js +347 -0
  31. package/src/ai/types.js +58 -0
  32. package/src/runtime.js +35 -21
  33. package/types/agent/sandbox-seam.d.ts +19 -6
  34. package/types/agent/tools/bash.d.ts +11 -26
  35. package/types/agent/tools/edit.d.ts +3 -2
  36. package/types/agent/tools/exec.d.ts +13 -26
  37. package/types/agent/tools/glob.d.ts +3 -2
  38. package/types/agent/tools/grep.d.ts +3 -2
  39. package/types/agent/tools/pi-bridge.d.ts +11 -4
  40. package/types/agent/tools/read.d.ts +3 -2
  41. package/types/agent/tools/shared/path-resolver.d.ts +8 -0
  42. package/types/agent/tools/shared/process-jobs.d.ts +64 -0
  43. package/types/agent/tools/shared/process-runner.d.ts +45 -3
  44. package/types/agent/tools/shared/protected-filesystem.d.ts +51 -0
  45. package/types/agent/tools/write.d.ts +3 -2
  46. package/types/ai/providers/pi-native/stream-subscriber.d.ts +2 -0
  47. package/types/ai/runtime/capabilities.d.ts +3 -0
  48. package/types/ai/streaming/codex-events.d.ts +1 -0
  49. package/types/ai/streaming/opencode-events.d.ts +1 -0
  50. package/types/ai/tool-lifecycle.d.ts +43 -0
  51. package/types/ai/types.d.ts +118 -0
@@ -4,6 +4,129 @@ import { spawn } from "node:child_process";
4
4
 
5
5
  export const DEFAULT_PROCESS_BUFFER_BYTES = 8 * 1024 * 1024;
6
6
  const KILL_GRACE_MS = 1_000;
7
+ const KILL_EXIT_CONFIRM_MS = 1_000;
8
+ const PROCESS_GROUP_REATTEST_POLL_MS = 25;
9
+ const PROCESS_GROUP_REATTEST_MAX_GAP_MS = 250;
10
+ const PROCESS_JOB_LAUNCH_PAYLOAD_BYTES = 8 * 1024 * 1024;
11
+ const PROCESS_JOB_STATUS_BYTES = 64 * 1024;
12
+
13
+ // Background jobs start this command-agnostic group leader first. The actual
14
+ // target crosses fd 3 only after the host has durably recorded the leader's
15
+ // PID, PGID, and incarnation. If the host exits before that release, fd 3
16
+ // closes empty and no target is ever spawned. Raw argv/environment values are
17
+ // therefore absent from both durable state and the gate's process arguments.
18
+ const PROCESS_JOB_LAUNCH_GATE_SOURCE = String.raw`
19
+ "use strict";
20
+ const { spawn } = require("node:child_process");
21
+ const { createReadStream, createWriteStream } = require("node:fs");
22
+ const MAX_PAYLOAD_BYTES = 8 * 1024 * 1024;
23
+ const input = createReadStream(null, { fd: 3, autoClose: true });
24
+ const chunks = [];
25
+ let bytes = 0;
26
+ let finished = false;
27
+ let target;
28
+ let groupTerminationSignal = null;
29
+
30
+ function finish(value) {
31
+ if (finished) return;
32
+ finished = true;
33
+ const output = createWriteStream(null, { fd: 4, autoClose: true });
34
+ output.once("error", () => { process.exitCode = 1; });
35
+ output.end(JSON.stringify(value), () => { process.exitCode = 0; });
36
+ }
37
+
38
+ function spawnFailure(code) {
39
+ finish({
40
+ code: null,
41
+ signal: null,
42
+ spawnError: {
43
+ message: "The gated target process could not be spawned.",
44
+ ...(typeof code === "string" ? { code } : {}),
45
+ },
46
+ });
47
+ }
48
+
49
+ // Group-wide SIGTERM also reaches this command-agnostic leader. Keep it alive
50
+ // while its direct target settles. Before release there cannot be a target, so
51
+ // close the gate and exit promptly instead of manufacturing a later spawn.
52
+ process.on("SIGTERM", () => {
53
+ groupTerminationSignal = "SIGTERM";
54
+ if (target !== undefined) return;
55
+ input.destroy();
56
+ finish({ code: null, signal: "SIGTERM", spawnError: null });
57
+ });
58
+
59
+ function forwardOutput(source, destination) {
60
+ let destinationOpen = true;
61
+ const resume = () => source.resume();
62
+ const discard = () => {
63
+ destinationOpen = false;
64
+ source.resume();
65
+ };
66
+ destination.on("drain", resume);
67
+ // If the owning host crashes, its pipe readers disappear. Keep the gate
68
+ // alive as the attestable group leader and drain target output instead of
69
+ // crashing on EPIPE and stranding descendants without a leader.
70
+ destination.on("error", discard);
71
+ source.on("data", (chunk) => {
72
+ if (!destinationOpen) return;
73
+ try {
74
+ if (!destination.write(chunk)) source.pause();
75
+ } catch {
76
+ discard();
77
+ }
78
+ });
79
+ }
80
+
81
+ input.on("data", (chunk) => {
82
+ bytes += chunk.length;
83
+ if (bytes > MAX_PAYLOAD_BYTES) {
84
+ input.destroy();
85
+ spawnFailure("PROCESS_JOB_LAUNCH_PAYLOAD_TOO_LARGE");
86
+ return;
87
+ }
88
+ chunks.push(chunk);
89
+ });
90
+ input.once("error", (error) => spawnFailure(error && error.code));
91
+ input.once("end", () => {
92
+ if (finished) return;
93
+ if (bytes === 0) {
94
+ process.exitCode = 0;
95
+ return;
96
+ }
97
+ let spec;
98
+ try {
99
+ spec = JSON.parse(Buffer.concat(chunks).toString("utf8"));
100
+ } catch {
101
+ spawnFailure("PROCESS_JOB_LAUNCH_PAYLOAD_INVALID");
102
+ return;
103
+ }
104
+ if (!spec || typeof spec.command !== "string" || !Array.isArray(spec.args)
105
+ || spec.args.some((argument) => typeof argument !== "string")
106
+ || (spec.cwd !== undefined && typeof spec.cwd !== "string")
107
+ || !spec.env || typeof spec.env !== "object" || Array.isArray(spec.env)) {
108
+ spawnFailure("PROCESS_JOB_LAUNCH_PAYLOAD_INVALID");
109
+ return;
110
+ }
111
+ try {
112
+ target = spawn(spec.command, spec.args, {
113
+ cwd: spec.cwd,
114
+ detached: false,
115
+ env: spec.env,
116
+ stdio: ["ignore", "pipe", "pipe"],
117
+ });
118
+ } catch (error) {
119
+ spawnFailure(error && error.code);
120
+ return;
121
+ }
122
+ forwardOutput(target.stdout, process.stdout);
123
+ forwardOutput(target.stderr, process.stderr);
124
+ target.once("error", (error) => spawnFailure(error && error.code));
125
+ target.once("close", (code, signal) => {
126
+ finish({ code, signal: signal || groupTerminationSignal, spawnError: null });
127
+ });
128
+ });
129
+ `;
7
130
 
8
131
  /**
9
132
  * Run one already-prepared executable without adding a shell.
@@ -13,7 +136,7 @@ const KILL_GRACE_MS = 1_000;
13
136
  * or exceeds that cap.
14
137
  *
15
138
  * @param {{command: string, args?: string[], cwd?: string, env?: Record<string, string|undefined>}} commandSpec
16
- * @param {{timeoutMs?: number, signal?: AbortSignal, maxBufferBytes?: number}} [options]
139
+ * @param {{timeoutMs?: number, signal?: AbortSignal, maxBufferBytes?: number, input?: string|Buffer}} [options]
17
140
  */
18
141
  export function runPreparedProcess(
19
142
  commandSpec,
@@ -21,19 +144,84 @@ export function runPreparedProcess(
21
144
  timeoutMs,
22
145
  signal,
23
146
  maxBufferBytes = DEFAULT_PROCESS_BUFFER_BYTES,
147
+ input,
148
+ } = {},
149
+ ) {
150
+ return startPreparedProcess(commandSpec, {
151
+ timeoutMs,
152
+ signal,
153
+ maxBufferBytes,
154
+ input,
155
+ }).completion;
156
+ }
157
+
158
+ /**
159
+ * Start one already-prepared executable and expose its process-group handle.
160
+ *
161
+ * `waitForProcessGroup` is deliberately opt-in so existing foreground tools
162
+ * retain their exact leader/stdio completion semantics. Process jobs enable it
163
+ * through their bound launcher: sandbox cleanup must not run while a detached
164
+ * descendant in the owned group is still alive.
165
+ *
166
+ * @param {{command: string, args?: string[], cwd?: string, env?: Record<string, string|undefined>}} commandSpec
167
+ * @param {{timeoutMs?: number, signal?: AbortSignal, maxBufferBytes?: number, input?: string|Buffer, waitForProcessGroup?: boolean, exactEnvironment?: boolean, onStdout?: (chunk: Buffer) => void, onStderr?: (chunk: Buffer) => void}} [options]
168
+ * For process jobs, `release()` is the persistence fence: the target cannot
169
+ * spawn until the host has durably recorded the returned ownership metadata.
170
+ * Foreground handles expose a harmless no-op release for one structural shape.
171
+ *
172
+ * @returns {{pid: number|null, pgid: number|null, startedAt: string, completion: Promise<any>, release: () => Promise<void>, cancel: () => void}}
173
+ */
174
+ export function startPreparedProcess(
175
+ commandSpec,
176
+ {
177
+ timeoutMs,
178
+ signal,
179
+ maxBufferBytes = DEFAULT_PROCESS_BUFFER_BYTES,
180
+ input,
181
+ waitForProcessGroup = false,
182
+ exactEnvironment = false,
183
+ onStdout,
184
+ onStderr,
24
185
  } = {},
25
186
  ) {
26
187
  const startedAt = Date.now();
27
- return new Promise((resolve) => {
28
- let child;
188
+ const gated = waitForProcessGroup && process.platform !== "win32";
189
+ const targetEnvironment = exactEnvironment
190
+ ? exactProcessEnv(commandSpec.env)
191
+ : commandSpec.env
192
+ ? mergedProcessEnv(commandSpec.env)
193
+ : { ...process.env };
194
+ const launchPayload = gated
195
+ ? Buffer.from(JSON.stringify({
196
+ command: commandSpec.command,
197
+ args: commandSpec.args || [],
198
+ ...(commandSpec.cwd === undefined ? {} : { cwd: commandSpec.cwd }),
199
+ env: targetEnvironment,
200
+ }), "utf8")
201
+ : null;
202
+ if (launchPayload !== null && launchPayload.byteLength > PROCESS_JOB_LAUNCH_PAYLOAD_BYTES) {
203
+ throw new RangeError("Process-job launch payload exceeds the safe in-memory gate limit.");
204
+ }
205
+ /** @type {import("node:child_process").ChildProcess|null} */
206
+ let child = null;
207
+ let cancel = () => {};
208
+ let release = async () => {};
209
+ const completion = new Promise((resolve) => {
29
210
  try {
30
- const env = commandSpec.env ? mergedProcessEnv(commandSpec.env) : process.env;
31
- child = spawn(commandSpec.command, commandSpec.args || [], {
32
- cwd: commandSpec.cwd,
211
+ child = spawn(
212
+ gated ? process.execPath : commandSpec.command,
213
+ gated
214
+ ? ["--input-type=commonjs", "--eval", PROCESS_JOB_LAUNCH_GATE_SOURCE]
215
+ : (commandSpec.args || []),
216
+ {
217
+ ...(gated ? {} : { cwd: commandSpec.cwd }),
33
218
  detached: process.platform !== "win32",
34
- env,
35
- stdio: ["ignore", "pipe", "pipe"],
36
- });
219
+ env: gated ? {} : targetEnvironment,
220
+ stdio: gated
221
+ ? ["ignore", "pipe", "pipe", "pipe", "pipe"]
222
+ : [input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
223
+ },
224
+ );
37
225
  } catch (error) {
38
226
  resolve({
39
227
  code: null,
@@ -52,6 +240,11 @@ export function runPreparedProcess(
52
240
  return;
53
241
  }
54
242
 
243
+ if (!gated && input !== undefined) {
244
+ child.stdin?.on("error", () => undefined);
245
+ child.stdin?.end(input);
246
+ }
247
+
55
248
  const stdout = [];
56
249
  const stderr = [];
57
250
  const state = {
@@ -63,20 +256,189 @@ export function runPreparedProcess(
63
256
  timedOut: false,
64
257
  truncated: false,
65
258
  };
259
+ let targetStatus = null;
260
+ let statusInvalid = false;
261
+ const statusChunks = [];
262
+ let statusBytes = 0;
66
263
  let killTimer = null;
264
+ let killExitTimer = null;
265
+ let groupProbeTimer = null;
67
266
  let timeoutTimer = null;
68
267
  let settled = false;
268
+ let childClosed = false;
269
+ let closeCode = null;
270
+ let closeSignal = null;
271
+ let groupAbsentObserved = false;
272
+ let terminationRequested = false;
273
+ let terminationFailure = null;
274
+ const ownedGroup = gated && child.pid ? createOwnedProcessGroupAttestation(child.pid) : null;
275
+
276
+ const finish = (groupExitConfirmed = true) => {
277
+ if (settled) return;
278
+ settled = true;
279
+ if (timeoutTimer !== null) clearTimeout(timeoutTimer);
280
+ if (killTimer !== null) clearTimeout(killTimer);
281
+ if (killExitTimer !== null) clearTimeout(killExitTimer);
282
+ if (groupProbeTimer !== null) clearTimeout(groupProbeTimer);
283
+ signal?.removeEventListener?.("abort", onAbort);
284
+ if (!childClosed) {
285
+ child?.stdout?.destroy();
286
+ child?.stderr?.destroy();
287
+ for (const stream of child?.stdio ?? []) stream?.destroy?.();
288
+ child?.unref?.();
289
+ }
290
+ const statusSpawnError = targetStatus?.spawnError
291
+ ? processSpawnError(targetStatus.spawnError)
292
+ : null;
293
+ const missingTargetStatus = gated
294
+ && targetStatus === null
295
+ && closeSignal === null
296
+ && closeCode !== 0
297
+ && !state.timedOut
298
+ && !state.bufferExceeded;
299
+ resolve({
300
+ code: targetStatus?.code ?? closeCode,
301
+ signal: targetStatus?.signal ?? closeSignal,
302
+ stdout: Buffer.concat(stdout).toString("utf8"),
303
+ stderr: Buffer.concat(stderr).toString("utf8"),
304
+ ...state,
305
+ spawnError: state.spawnError
306
+ ?? statusSpawnError
307
+ ?? (terminationFailure !== null
308
+ ? new Error(terminationFailure)
309
+ : statusInvalid || missingTargetStatus
310
+ ? new Error("The process-job launch gate exited without a valid target result.")
311
+ : null),
312
+ groupExitConfirmed,
313
+ durationMs: Date.now() - startedAt,
314
+ });
315
+ };
316
+
317
+ const probeOwnedGroup = () => {
318
+ groupProbeTimer = null;
319
+ if (settled || !child?.pid || !waitForProcessGroup || process.platform === "win32") return;
320
+ // A definitely live, unreaped self-led ChildProcess prevents its numeric
321
+ // PGID from being reused. Start continuity polling only after that exact
322
+ // leader reports exit; long-running jobs need no periodic re-attestation.
323
+ if (ownedGroup !== null && !ownedGroup.leaderExited) return;
324
+ const presence = ownedProcessGroupPresence(child.pid);
325
+ if (presence === "absent") {
326
+ groupAbsentObserved = true;
327
+ if (ownedGroup !== null) ownedGroup.ownershipLost = true;
328
+ if (childClosed) finish(true);
329
+ return;
330
+ }
331
+ if (ownedGroup !== null) {
332
+ if (presence === "present") {
333
+ const observedAt = Date.now();
334
+ if (ownedGroup.lastObservedAt === null
335
+ || observedAt - ownedGroup.lastObservedAt > PROCESS_GROUP_REATTEST_MAX_GAP_MS) {
336
+ // A missed observation window can hide group disappearance and
337
+ // numeric reuse. Once continuity is lost, later presence can never
338
+ // restore authority over that PGID.
339
+ ownedGroup.ownershipLost = true;
340
+ } else if (!ownedGroup.ownershipLost) {
341
+ if (ownedGroup.leaderExited) ownedGroup.observedAfterLeaderExit = true;
342
+ ownedGroup.lastObservedAt = observedAt;
343
+ }
344
+ } else {
345
+ // An indeterminate probe cannot extend numeric PGID authority.
346
+ ownedGroup.ownershipLost = true;
347
+ }
348
+ }
349
+ groupProbeTimer = setTimeout(probeOwnedGroup, PROCESS_GROUP_REATTEST_POLL_MS);
350
+ groupProbeTimer.unref?.();
351
+ };
69
352
 
70
353
  function terminate() {
71
- killProcessGroup(child, "SIGTERM");
72
- if (killTimer === null) {
73
- killTimer = setTimeout(() => killProcessGroup(child, "SIGKILL"), KILL_GRACE_MS);
74
- killTimer.unref?.();
354
+ if (settled || terminationRequested) return;
355
+ terminationRequested = true;
356
+ const term = signalAttestedProcessGroup(child, ownedGroup, "SIGTERM");
357
+ if (term === "absent") {
358
+ finish(true);
359
+ return;
360
+ }
361
+ if (term === "unproven") {
362
+ terminationFailure = "Owned process-group termination was withheld because its surviving identity could not be re-attested.";
75
363
  }
364
+ killTimer = setTimeout(() => {
365
+ const kill = signalAttestedProcessGroup(child, ownedGroup, "SIGKILL");
366
+ if (kill === "absent") {
367
+ finish(true);
368
+ return;
369
+ }
370
+ if (kill === "unproven") {
371
+ terminationFailure = "Owned process-group escalation was withheld because its surviving identity could not be re-attested.";
372
+ }
373
+ // SIGKILL should make the group disappear promptly, but a kernel/OS
374
+ // anomaly or lost ownership proof must still settle explicitly. The
375
+ // caller can then withhold sandbox cleanup instead of hanging forever.
376
+ killExitTimer = setTimeout(() => {
377
+ const absent = child?.pid
378
+ ? ownedProcessGroupPresence(child.pid) === "absent"
379
+ : true;
380
+ if (!absent && terminationFailure === null) {
381
+ terminationFailure = "Owned process-group exit could not be confirmed after SIGKILL.";
382
+ }
383
+ finish(absent);
384
+ }, KILL_EXIT_CONFIRM_MS);
385
+ killExitTimer.unref?.();
386
+ }, KILL_GRACE_MS);
387
+ killTimer.unref?.();
76
388
  }
389
+ cancel = terminate;
77
390
 
78
- function append(target, chunk) {
391
+ if (gated) {
392
+ const gate = /** @type {import("node:stream").Writable|undefined} */ (child.stdio?.[3]);
393
+ const status = /** @type {import("node:stream").Readable|undefined} */ (child.stdio?.[4]);
394
+ let releasePromise;
395
+ release = async () => {
396
+ releasePromise ??= new Promise((resolveRelease, rejectRelease) => {
397
+ if (!gate || typeof gate.end !== "function" || launchPayload === null) {
398
+ rejectRelease(new Error("Process-job launch gate is unavailable."));
399
+ return;
400
+ }
401
+ let released = false;
402
+ const rejectOnce = () => {
403
+ if (released) return;
404
+ released = true;
405
+ rejectRelease(new Error("Process-job launch gate closed before release."));
406
+ };
407
+ gate.once("error", rejectOnce);
408
+ gate.end(launchPayload, () => {
409
+ if (released) return;
410
+ released = true;
411
+ gate.off("error", rejectOnce);
412
+ resolveRelease();
413
+ });
414
+ });
415
+ await releasePromise;
416
+ };
417
+ status?.on("data", (chunk) => {
418
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
419
+ statusBytes += buffer.length;
420
+ if (statusBytes > PROCESS_JOB_STATUS_BYTES) {
421
+ statusInvalid = true;
422
+ return;
423
+ }
424
+ statusChunks.push(buffer);
425
+ });
426
+ status?.once("error", () => { statusInvalid = true; });
427
+ status?.once("end", () => {
428
+ if (statusInvalid || statusBytes === 0) return;
429
+ try {
430
+ const parsed = JSON.parse(Buffer.concat(statusChunks).toString("utf8"));
431
+ if (validTargetStatus(parsed)) targetStatus = parsed;
432
+ else statusInvalid = true;
433
+ } catch {
434
+ statusInvalid = true;
435
+ }
436
+ });
437
+ }
438
+
439
+ function append(target, chunk, observe) {
79
440
  const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
441
+ try { observe?.(buffer); } catch { /* observers cannot break process ownership */ }
80
442
  state.bytes += buffer.length;
81
443
  const remaining = Math.max(0, maxBufferBytes - state.storedBytes);
82
444
  if (remaining > 0) {
@@ -106,27 +468,157 @@ export function runPreparedProcess(
106
468
  if (signal?.aborted) onAbort();
107
469
  else signal?.addEventListener?.("abort", onAbort, { once: true });
108
470
 
109
- child.stdout?.on("data", (chunk) => append(stdout, chunk));
110
- child.stderr?.on("data", (chunk) => append(stderr, chunk));
471
+ child.stdout?.on("data", (chunk) => append(stdout, chunk, onStdout));
472
+ child.stderr?.on("data", (chunk) => append(stderr, chunk, onStderr));
111
473
  child.once("error", (error) => {
112
474
  state.spawnError = error;
113
475
  });
114
- child.once("close", (code, closeSignal) => {
476
+ // Refresh the continuity proof at the kernel exit notification, not
477
+ // `close`: inherited stdio can delay `close` while an in-group descendant
478
+ // survives.
479
+ child.once("exit", (code, childExitSignal) => {
480
+ closeCode = code;
481
+ closeSignal = childExitSignal;
482
+ if (ownedGroup !== null) {
483
+ ownedGroup.leaderExited = true;
484
+ // The reuse hazard begins only when the exact self-led ChildProcess
485
+ // exits. Spawn-to-exit event-loop gaps cannot revoke live authority.
486
+ ownedGroup.lastObservedAt = Date.now();
487
+ }
488
+ if (groupProbeTimer !== null) clearTimeout(groupProbeTimer);
489
+ groupProbeTimer = null;
490
+ probeOwnedGroup();
491
+ });
492
+ child.once("close", (code, childCloseSignal) => {
115
493
  if (settled) return;
116
- settled = true;
117
- if (timeoutTimer !== null) clearTimeout(timeoutTimer);
118
- if (killTimer !== null) clearTimeout(killTimer);
119
- signal?.removeEventListener?.("abort", onAbort);
120
- resolve({
121
- code,
122
- signal: closeSignal,
123
- stdout: Buffer.concat(stdout).toString("utf8"),
124
- stderr: Buffer.concat(stderr).toString("utf8"),
125
- ...state,
126
- durationMs: Date.now() - startedAt,
127
- });
494
+ childClosed = true;
495
+ closeCode = code;
496
+ closeSignal = childCloseSignal;
497
+ if (!waitForProcessGroup || process.platform === "win32" || !child?.pid) {
498
+ finish(true);
499
+ return;
500
+ }
501
+ if (groupAbsentObserved) finish(true);
502
+ else if (groupProbeTimer === null) probeOwnedGroup();
128
503
  });
129
504
  });
505
+ return {
506
+ pid: child?.pid ?? null,
507
+ pgid: process.platform === "win32" ? null : (child?.pid ?? null),
508
+ startedAt: new Date(startedAt).toISOString(),
509
+ completion,
510
+ release: () => release(),
511
+ cancel: () => cancel(),
512
+ };
513
+ }
514
+
515
+ function validTargetStatus(value) {
516
+ if (!value || typeof value !== "object") return false;
517
+ if (value.code !== null && !Number.isInteger(value.code)) return false;
518
+ if (value.signal !== null && typeof value.signal !== "string") return false;
519
+ if (value.spawnError === null) return true;
520
+ return typeof value.spawnError === "object"
521
+ && typeof value.spawnError.message === "string"
522
+ && (value.spawnError.code === undefined || typeof value.spawnError.code === "string");
523
+ }
524
+
525
+ function processSpawnError(value) {
526
+ return Object.assign(
527
+ new Error(value.message),
528
+ typeof value.code === "string" ? { code: value.code } : {},
529
+ );
530
+ }
531
+
532
+ function createOwnedProcessGroupAttestation(pgid) {
533
+ return {
534
+ pgid,
535
+ leaderExited: false,
536
+ observedAfterLeaderExit: false,
537
+ ownershipLost: false,
538
+ /** @type {number|null} */
539
+ lastObservedAt: null,
540
+ };
541
+ }
542
+
543
+ function reattestOwnedProcessGroup(attestation) {
544
+ if (attestation.ownershipLost
545
+ || !attestation.leaderExited
546
+ || !attestation.observedAfterLeaderExit
547
+ || attestation.lastObservedAt === null) return false;
548
+ if (Date.now() - attestation.lastObservedAt > PROCESS_GROUP_REATTEST_MAX_GAP_MS) {
549
+ attestation.ownershipLost = true;
550
+ return false;
551
+ }
552
+ const presence = ownedProcessGroupPresence(attestation.pgid);
553
+ if (presence === "absent") {
554
+ attestation.ownershipLost = true;
555
+ return false;
556
+ }
557
+ if (presence !== "present") {
558
+ // An indeterminate observation is itself a continuity gap. A later
559
+ // successful probe must never restore authority over the numeric PGID.
560
+ attestation.ownershipLost = true;
561
+ return false;
562
+ }
563
+ // POSIX cannot reuse the numeric PGID while any member of that exact group
564
+ // survives. Continuous polling begins when the exact detached leader exits
565
+ // and permanently revokes authority on an observation gap or the first
566
+ // observed absence; a later group using the number is never signalled.
567
+ attestation.observedAfterLeaderExit = true;
568
+ attestation.lastObservedAt = Date.now();
569
+ return true;
570
+ }
571
+
572
+ function signalAttestedProcessGroup(child, attestation, signal) {
573
+ if (!child?.pid) return "absent";
574
+ // While the exact self-led ChildProcess is definitely live and unreaped,
575
+ // POSIX cannot recycle its PID/PGID. Event-loop stalls do not weaken that
576
+ // kernel-backed authority, so signal the negative PGID directly.
577
+ if (!processLeaderExited(child)) {
578
+ try {
579
+ process.kill(process.platform === "win32" ? child.pid : -child.pid, signal);
580
+ return "signalled";
581
+ } catch (error) {
582
+ if (error?.code === "ESRCH") return "absent";
583
+ return "unproven";
584
+ }
585
+ }
586
+ if (process.platform === "win32") return "absent";
587
+ if (attestation === null || !attestation.leaderExited) return "unproven";
588
+ const continuityExpired = attestation.lastObservedAt === null
589
+ || Date.now() - attestation.lastObservedAt > PROCESS_GROUP_REATTEST_MAX_GAP_MS;
590
+ if (continuityExpired) attestation.ownershipLost = true;
591
+ if (attestation.ownershipLost) {
592
+ // After leader exit, a lost observation window can hide disappearance and
593
+ // numeric reuse. Only observed absence can now confirm completion.
594
+ return ownedProcessGroupPresence(attestation.pgid) === "absent"
595
+ ? "absent"
596
+ : "unproven";
597
+ }
598
+ const presence = ownedProcessGroupPresence(child.pid);
599
+ if (presence === "absent") return "absent";
600
+ if (presence !== "present") {
601
+ if (attestation !== null) attestation.ownershipLost = true;
602
+ return "unproven";
603
+ }
604
+ if (!reattestOwnedProcessGroup(attestation)) return "unproven";
605
+ try {
606
+ process.kill(-child.pid, signal);
607
+ return "signalled";
608
+ } catch (error) {
609
+ if (error?.code === "ESRCH") return "absent";
610
+ return "unproven";
611
+ }
612
+ }
613
+
614
+ function ownedProcessGroupPresence(pgid) {
615
+ try {
616
+ process.kill(-pgid, 0);
617
+ return "present";
618
+ } catch (error) {
619
+ if (error?.code === "ESRCH") return "absent";
620
+ return "unknown";
621
+ }
130
622
  }
131
623
 
132
624
  function mergedProcessEnv(overrides) {
@@ -138,19 +630,38 @@ function mergedProcessEnv(overrides) {
138
630
  return env;
139
631
  }
140
632
 
633
+ function exactProcessEnv(values = {}) {
634
+ /** @type {Record<string, string>} */
635
+ const env = {};
636
+ for (const [key, value] of Object.entries(values)) {
637
+ if (value !== undefined) env[key] = value;
638
+ }
639
+ return env;
640
+ }
641
+
141
642
  /**
142
643
  * @param {import("node:child_process").ChildProcess} child
143
644
  * @param {NodeJS.Signals} signal
645
+ * @param {{fallbackToChildPid?: boolean}} [options]
144
646
  */
145
- export function killProcessGroup(child, signal) {
647
+ export function killProcessGroup(child, signal, { fallbackToChildPid = false } = {}) {
146
648
  if (!child?.pid) return;
147
649
  try {
148
650
  process.kill(process.platform === "win32" ? child.pid : -child.pid, signal);
149
651
  } catch {
652
+ // Node REPL owns an exact, still-live ChildProcess and opts into the
653
+ // legacy single-PID fallback. Attested process jobs never use this helper.
654
+ if (!fallbackToChildPid || process.platform === "win32" || processLeaderExited(child)) return;
150
655
  try { process.kill(child.pid, signal); } catch { /* already gone */ }
151
656
  }
152
657
  }
153
658
 
659
+ function processLeaderExited(child) {
660
+ return !child?.pid
661
+ || (child.exitCode !== null && child.exitCode !== undefined)
662
+ || (child.signalCode !== null && child.signalCode !== undefined);
663
+ }
664
+
154
665
  /**
155
666
  * @param {{stdout?: string, stderr?: string}} result
156
667
  */