@diffexai/diffex 0.2.7 → 0.3.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 (63) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/cli/args.d.ts +2 -0
  3. package/dist/cli/args.js +39 -0
  4. package/dist/core/agent-session-services.d.ts +1 -0
  5. package/dist/core/agent-session-services.js +1 -0
  6. package/dist/core/agent-session.d.ts +9 -0
  7. package/dist/core/agent-session.js +62 -8
  8. package/dist/core/compaction/compaction.js +3 -1
  9. package/dist/core/sdk.d.ts +5 -1
  10. package/dist/core/sdk.js +19 -6
  11. package/dist/core/subagents/controller.d.ts +4 -3
  12. package/dist/core/subagents/controller.js +173 -37
  13. package/dist/core/subagents/services.d.ts +2 -0
  14. package/dist/core/subagents/services.js +2 -0
  15. package/dist/core/subagents/types.d.ts +13 -0
  16. package/dist/core/tools/bash.d.ts +4 -0
  17. package/dist/core/tools/bash.js +55 -11
  18. package/dist/core/tools/subagents.d.ts +12 -10
  19. package/dist/core/tools/subagents.js +61 -49
  20. package/dist/index.d.ts +1 -0
  21. package/dist/main.js +4 -0
  22. package/dist/modes/print-mode.js +16 -13
  23. package/dist/node_modules/@diffexai/diffex-agent-core/distribution-components.json +4 -4
  24. package/dist/node_modules/@diffexai/diffex-agent-core/distribution-files.json +1 -1
  25. package/dist/node_modules/@diffexai/diffex-agent-core/package.json +1 -1
  26. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/.manifest.json +1 -1
  27. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/opencode-go.json +1 -1
  28. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/opencode.json +1 -1
  29. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/openrouter.json +1 -1
  30. package/dist/node_modules/@diffexai/diffex-ai/dist/providers/data/vercel-ai-gateway.json +1 -1
  31. package/dist/node_modules/@diffexai/diffex-ai/distribution-components.json +3 -3
  32. package/dist/node_modules/@diffexai/diffex-ai/distribution-files.json +6 -6
  33. package/dist/node_modules/@diffexai/diffex-ai/package.json +1 -1
  34. package/dist/node_modules/@diffexai/diffex-client/distribution-components.json +3 -3
  35. package/dist/node_modules/@diffexai/diffex-client/distribution-files.json +1 -1
  36. package/dist/node_modules/@diffexai/diffex-client/package.json +1 -1
  37. package/dist/node_modules/@diffexai/diffex-harness-state/distribution-components.json +2 -2
  38. package/dist/node_modules/@diffexai/diffex-harness-state/distribution-files.json +1 -1
  39. package/dist/node_modules/@diffexai/diffex-harness-state/package.json +1 -1
  40. package/dist/node_modules/@diffexai/diffex-protocol/distribution-components.json +2 -2
  41. package/dist/node_modules/@diffexai/diffex-protocol/distribution-files.json +1 -1
  42. package/dist/node_modules/@diffexai/diffex-protocol/package.json +1 -1
  43. package/dist/node_modules/@diffexai/diffex-telemetry/distribution-components.json +2 -2
  44. package/dist/node_modules/@diffexai/diffex-telemetry/distribution-files.json +1 -1
  45. package/dist/node_modules/@diffexai/diffex-telemetry/package.json +1 -1
  46. package/dist/node_modules/@diffexai/diffex-tui/distribution-components.json +2 -2
  47. package/dist/node_modules/@diffexai/diffex-tui/distribution-files.json +1 -1
  48. package/dist/node_modules/@diffexai/diffex-tui/package.json +1 -1
  49. package/dist/utils/child-process.d.ts +5 -1
  50. package/dist/utils/child-process.js +9 -1
  51. package/dist/utils/owned-shell-processes.d.ts +29 -0
  52. package/dist/utils/owned-shell-processes.js +171 -0
  53. package/dist/utils/shell-process-identity.d.ts +11 -0
  54. package/dist/utils/shell-process-identity.js +61 -0
  55. package/dist/utils/shell.d.ts +1 -1
  56. package/dist/utils/shell.js +5 -5
  57. package/distribution-components.json +11 -11
  58. package/distribution-files.json +68 -52
  59. package/npm-shrinkwrap.json +2 -2
  60. package/package.json +1 -1
  61. package/release/distribution-manifest.json +4 -4
  62. package/release/install-package-lock.json +5 -5
  63. package/release/install-package.json +2 -2
@@ -1,8 +1,8 @@
1
1
  [
2
2
  {
3
3
  "name": "@diffexai/diffex-ai",
4
- "version": "0.2.7",
5
- "sourceCommit": "598159fca2acfacdf5374205b5a5e46afe0af58a",
4
+ "version": "0.3.1",
5
+ "sourceCommit": "9d59a28db4888b6824740505f0a16146667f036b",
6
6
  "license": "Apache-2.0",
7
7
  "portions": "Diffex contributions; Pi-derived portions retain MIT terms in LICENSE",
8
8
  "notice": "LICENSE",
@@ -20,7 +20,7 @@
20
20
  },
21
21
  {
22
22
  "name": "@diffexai/diffex-telemetry",
23
- "version": "^0.2.7",
23
+ "version": "^0.3.1",
24
24
  "distribution": "resolved separately by package manager; not bundled in this npm tarball"
25
25
  },
26
26
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "sourceCommit": "598159fca2acfacdf5374205b5a5e46afe0af58a",
2
+ "sourceCommit": "9d59a28db4888b6824740505f0a16146667f036b",
3
3
  "algorithm": "sha256",
4
4
  "files": [
5
5
  {
@@ -676,7 +676,7 @@
676
676
  },
677
677
  {
678
678
  "path": "dist/providers/data/.manifest.json",
679
- "sha256": "2cf674e48ce768bfc3ac3b97b175a9d6a53acf00452ee7d1b9c3ead6c6daee7e"
679
+ "sha256": "e7349cfa428948144bd94d1d6327eb45db4c3d8b50e22d2f10d030d7fb62ad8d"
680
680
  },
681
681
  {
682
682
  "path": "dist/providers/data/amazon-bedrock.json",
@@ -776,15 +776,15 @@
776
776
  },
777
777
  {
778
778
  "path": "dist/providers/data/opencode-go.json",
779
- "sha256": "31c4434316c8a1b7fcc637b25f1147dd44da946a0b932ca10fb5ac594261dad7"
779
+ "sha256": "fa6d6737da943fbb9c36487cb4cd5bc152d1f2fd548280bbe9d35d0304e79f11"
780
780
  },
781
781
  {
782
782
  "path": "dist/providers/data/opencode.json",
783
- "sha256": "ce7fcaa9e6da77b00d875b42db03d39332c259338e8e1632e74126d663c8405b"
783
+ "sha256": "43c5d9cc6fab109411e6ac8eebac4b9fb0b00cff844cf0eb6c1559a08586525f"
784
784
  },
785
785
  {
786
786
  "path": "dist/providers/data/openrouter.json",
787
- "sha256": "828095707e092be2e68b8c6a052588188ef651f68d4097d3d72ab183fb5c7b81"
787
+ "sha256": "a3375b66ad7b4bb61f22d867e132c991d3b9851436c529838c109b9794d59b93"
788
788
  },
789
789
  {
790
790
  "path": "dist/providers/data/qwen-token-plan-cn.json",
@@ -804,7 +804,7 @@
804
804
  },
805
805
  {
806
806
  "path": "dist/providers/data/vercel-ai-gateway.json",
807
- "sha256": "e61d139444d16c7900a141d0bc9af874761794e80fb7d3ff4a08a21c3e93231f"
807
+ "sha256": "03b0830a794af1bc921fb504565534bedec660fcc9482adcf56ec5fb27703e60"
808
808
  },
809
809
  {
810
810
  "path": "dist/providers/data/xai.json",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diffexai/diffex-ai",
3
- "version": "0.2.7",
3
+ "version": "0.3.1",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,8 +1,8 @@
1
1
  [
2
2
  {
3
3
  "name": "@diffexai/diffex-client",
4
- "version": "0.2.7",
5
- "sourceCommit": "598159fca2acfacdf5374205b5a5e46afe0af58a",
4
+ "version": "0.3.1",
5
+ "sourceCommit": "9d59a28db4888b6824740505f0a16146667f036b",
6
6
  "license": "Apache-2.0",
7
7
  "portions": "Diffex contributions; Pi-derived portions retain MIT terms in LICENSE",
8
8
  "notice": "LICENSE",
@@ -10,7 +10,7 @@
10
10
  },
11
11
  {
12
12
  "name": "@diffexai/diffex-protocol",
13
- "version": "^0.2.7",
13
+ "version": "^0.3.1",
14
14
  "distribution": "resolved separately by package manager; not bundled in this npm tarball"
15
15
  }
16
16
  ]
@@ -1,5 +1,5 @@
1
1
  {
2
- "sourceCommit": "598159fca2acfacdf5374205b5a5e46afe0af58a",
2
+ "sourceCommit": "9d59a28db4888b6824740505f0a16146667f036b",
3
3
  "algorithm": "sha256",
4
4
  "files": [
5
5
  {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diffexai/diffex-client",
3
- "version": "0.2.7",
3
+ "version": "0.3.1",
4
4
  "description": "Transport-neutral client for remote Diffex sessions over framed CBOR bytes",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,8 +1,8 @@
1
1
  [
2
2
  {
3
3
  "name": "@diffexai/diffex-harness-state",
4
- "version": "0.2.7",
5
- "sourceCommit": "598159fca2acfacdf5374205b5a5e46afe0af58a",
4
+ "version": "0.3.1",
5
+ "sourceCommit": "9d59a28db4888b6824740505f0a16146667f036b",
6
6
  "license": "Apache-2.0",
7
7
  "portions": "Diffex contributions; Pi-derived portions retain MIT terms in LICENSE",
8
8
  "notice": "LICENSE",
@@ -1,5 +1,5 @@
1
1
  {
2
- "sourceCommit": "598159fca2acfacdf5374205b5a5e46afe0af58a",
2
+ "sourceCommit": "9d59a28db4888b6824740505f0a16146667f036b",
3
3
  "algorithm": "sha256",
4
4
  "files": [
5
5
  {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diffexai/diffex-harness-state",
3
- "version": "0.2.7",
3
+ "version": "0.3.1",
4
4
  "description": "Persistent harness state schema and bundle storage for Diffex",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,8 +1,8 @@
1
1
  [
2
2
  {
3
3
  "name": "@diffexai/diffex-protocol",
4
- "version": "0.2.7",
5
- "sourceCommit": "598159fca2acfacdf5374205b5a5e46afe0af58a",
4
+ "version": "0.3.1",
5
+ "sourceCommit": "9d59a28db4888b6824740505f0a16146667f036b",
6
6
  "license": "Apache-2.0",
7
7
  "portions": "Diffex contributions; Pi-derived portions retain MIT terms in LICENSE",
8
8
  "notice": "LICENSE",
@@ -1,5 +1,5 @@
1
1
  {
2
- "sourceCommit": "598159fca2acfacdf5374205b5a5e46afe0af58a",
2
+ "sourceCommit": "9d59a28db4888b6824740505f0a16146667f036b",
3
3
  "algorithm": "sha256",
4
4
  "files": [
5
5
  {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diffexai/diffex-protocol",
3
- "version": "0.2.7",
3
+ "version": "0.3.1",
4
4
  "description": "Transport-neutral CBOR protocol for remote Diffex sessions",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,8 +1,8 @@
1
1
  [
2
2
  {
3
3
  "name": "@diffexai/diffex-telemetry",
4
- "version": "0.2.7",
5
- "sourceCommit": "598159fca2acfacdf5374205b5a5e46afe0af58a",
4
+ "version": "0.3.1",
5
+ "sourceCommit": "9d59a28db4888b6824740505f0a16146667f036b",
6
6
  "license": "Apache-2.0",
7
7
  "portions": "Diffex contributions; Pi-derived portions retain MIT terms in LICENSE",
8
8
  "notice": "LICENSE",
@@ -1,5 +1,5 @@
1
1
  {
2
- "sourceCommit": "598159fca2acfacdf5374205b5a5e46afe0af58a",
2
+ "sourceCommit": "9d59a28db4888b6824740505f0a16146667f036b",
3
3
  "algorithm": "sha256",
4
4
  "files": [
5
5
  {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diffexai/diffex-telemetry",
3
- "version": "0.2.7",
3
+ "version": "0.3.1",
4
4
  "description": "Vendor-neutral telemetry contracts and typed schema utilities for Diffex",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,8 +1,8 @@
1
1
  [
2
2
  {
3
3
  "name": "@diffexai/diffex-tui",
4
- "version": "0.2.7",
5
- "sourceCommit": "598159fca2acfacdf5374205b5a5e46afe0af58a",
4
+ "version": "0.3.1",
5
+ "sourceCommit": "9d59a28db4888b6824740505f0a16146667f036b",
6
6
  "license": "Apache-2.0",
7
7
  "portions": "Diffex contributions; Pi-derived portions retain MIT terms in LICENSE",
8
8
  "notice": "LICENSE",
@@ -1,5 +1,5 @@
1
1
  {
2
- "sourceCommit": "598159fca2acfacdf5374205b5a5e46afe0af58a",
2
+ "sourceCommit": "9d59a28db4888b6824740505f0a16146667f036b",
3
3
  "algorithm": "sha256",
4
4
  "files": [
5
5
  {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diffexai/diffex-tui",
3
- "version": "0.2.7",
3
+ "version": "0.3.1",
4
4
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -13,5 +13,9 @@ export declare function spawnProcessSync(command: string, args: string[], option
13
13
  * the grace timer is re-armed on every chunk, so an actively writing descendant keeps
14
14
  * us reading, while a quiet inherited handle (e.g. a Windows daemonized descendant
15
15
  * that never lets `close` fire) still releases us after the grace elapses.
16
+ * Callers handing background jobs to an environment can explicitly bound draining
17
+ * with maxPostExitMs so ongoing service output cannot block session disposal.
16
18
  */
17
- export declare function waitForChildProcess(child: ChildProcess): Promise<number | null>;
19
+ export declare function waitForChildProcess(child: ChildProcess, options?: {
20
+ maxPostExitMs?: number;
21
+ }): Promise<number | null>;
@@ -19,16 +19,21 @@ export function spawnProcessSync(command, args, options) {
19
19
  * the grace timer is re-armed on every chunk, so an actively writing descendant keeps
20
20
  * us reading, while a quiet inherited handle (e.g. a Windows daemonized descendant
21
21
  * that never lets `close` fire) still releases us after the grace elapses.
22
+ * Callers handing background jobs to an environment can explicitly bound draining
23
+ * with maxPostExitMs so ongoing service output cannot block session disposal.
22
24
  */
23
- export function waitForChildProcess(child) {
25
+ export function waitForChildProcess(child, options) {
24
26
  return new Promise((resolve, reject) => {
25
27
  let settled = false;
26
28
  let exited = false;
27
29
  let exitCode = null;
28
30
  let postExitTimer;
31
+ let postExitDeadline;
29
32
  let stdoutEnded = child.stdout === null;
30
33
  let stderrEnded = child.stderr === null;
31
34
  const cleanup = () => {
35
+ if (postExitDeadline)
36
+ clearTimeout(postExitDeadline);
32
37
  if (postExitTimer) {
33
38
  clearTimeout(postExitTimer);
34
39
  postExitTimer = undefined;
@@ -88,6 +93,9 @@ export function waitForChildProcess(child) {
88
93
  exitCode = code;
89
94
  maybeFinalizeAfterExit();
90
95
  if (!settled) {
96
+ if (options?.maxPostExitMs !== undefined) {
97
+ postExitDeadline = setTimeout(() => finalize(exitCode), options.maxPostExitMs);
98
+ }
91
99
  armIdleTimer();
92
100
  }
93
101
  };
@@ -0,0 +1,29 @@
1
+ import { type ShellProcessIdentity } from "./shell-process-identity.ts";
2
+ export type BackgroundProcessPolicy = {
3
+ readonly lifetime: "session";
4
+ } | {
5
+ readonly lifetime: "environment";
6
+ readonly ownershipDirectory: string;
7
+ };
8
+ export interface ShellOwnershipRecord {
9
+ readonly version: 1;
10
+ readonly executionId: string;
11
+ readonly owner: "session" | "environment";
12
+ readonly state: "active" | "background" | "terminated";
13
+ readonly processGroupId: number;
14
+ readonly supervisorPid: number;
15
+ readonly members: readonly ShellProcessIdentity[];
16
+ }
17
+ /** Session ownership ends only after a successful, durably published handoff. */
18
+ export declare class OwnedShellProcesses {
19
+ #private;
20
+ readonly policy: BackgroundProcessPolicy;
21
+ constructor(policy?: BackgroundProcessPolicy);
22
+ track(pid: number, executionId: string): void;
23
+ /** Called synchronously on shell exit, before output draining or another event can cancel it. */
24
+ complete(pid: number, successful: boolean): void;
25
+ terminate(pid: number): void;
26
+ releaseIfExited(pid: number): void;
27
+ get count(): number;
28
+ terminateAll(): Promise<void>;
29
+ }
@@ -0,0 +1,171 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { closeSync, fsyncSync, mkdirSync, openSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { isAbsolute, join } from "node:path";
4
+ import { setTimeout as delay } from "node:timers/promises";
5
+ import { killProcessTree, trackDetachedChildPid, untrackDetachedChildPid } from "./shell.js";
6
+ import { hasShellExecutionToken, readShellProcessGroup } from "./shell-process-identity.js";
7
+ /** Session ownership ends only after a successful, durably published handoff. */
8
+ export class OwnedShellProcesses {
9
+ policy;
10
+ #executions = new Map();
11
+ constructor(policy = { lifetime: "session" }) {
12
+ if (policy.lifetime !== "session" && policy.lifetime !== "environment") {
13
+ throw new Error("Invalid background process lifetime");
14
+ }
15
+ if (policy.lifetime === "environment") {
16
+ if (process.platform !== "linux" && process.platform !== "darwin") {
17
+ throw new Error("Environment background process ownership requires Linux or macOS");
18
+ }
19
+ if (!policy.ownershipDirectory || !isAbsolute(policy.ownershipDirectory)) {
20
+ throw new Error("Environment background process ownership requires an absolute ownershipDirectory");
21
+ }
22
+ mkdirSync(policy.ownershipDirectory, { recursive: true, mode: 0o700 });
23
+ }
24
+ this.policy = Object.freeze({ ...policy });
25
+ }
26
+ track(pid, executionId) {
27
+ const execution = { id: executionId, state: "active", members: [] };
28
+ this.#executions.set(pid, execution);
29
+ trackDetachedChildPid(pid, () => this.terminate(pid));
30
+ try {
31
+ execution.members = process.platform === "win32" ? [] : readShellProcessGroup(pid);
32
+ this.#publish(pid, execution, "session", "active");
33
+ }
34
+ catch (error) {
35
+ this.terminate(pid);
36
+ throw error;
37
+ }
38
+ }
39
+ /** Called synchronously on shell exit, before output draining or another event can cancel it. */
40
+ complete(pid, successful) {
41
+ const execution = this.#executions.get(pid);
42
+ if (!execution || execution.state !== "active")
43
+ return;
44
+ // This callback belongs to the ChildProcess just reaped by Node. Capture the
45
+ // surviving group now; later cleanup must validate these process identities.
46
+ execution.members = process.platform === "win32" ? [] : readShellProcessGroup(pid);
47
+ execution.state = "background";
48
+ if (!successful || this.policy.lifetime === "session")
49
+ return;
50
+ const members = this.#members(pid, execution);
51
+ if (members.length === 0) {
52
+ this.releaseIfExited(pid);
53
+ return;
54
+ }
55
+ execution.members = members;
56
+ // No await between publication and removing both cleanup registrations.
57
+ // Atomic rename is the ownership commit point, including after a forced CLI death.
58
+ this.#publish(pid, execution, "environment", "background");
59
+ this.#executions.delete(pid);
60
+ untrackDetachedChildPid(pid);
61
+ }
62
+ #publish(pid, execution, owner, state) {
63
+ if (this.policy.lifetime !== "environment")
64
+ return;
65
+ const record = {
66
+ version: 1,
67
+ executionId: execution.id,
68
+ owner,
69
+ state,
70
+ processGroupId: pid,
71
+ supervisorPid: process.pid,
72
+ members: execution.members,
73
+ };
74
+ const path = join(this.policy.ownershipDirectory, `${execution.id}.json`);
75
+ const temporary = `${path}.${randomUUID()}.tmp`;
76
+ const fd = openSync(temporary, "wx", 0o600);
77
+ try {
78
+ try {
79
+ writeFileSync(fd, `${JSON.stringify(record)}\n`);
80
+ fsyncSync(fd);
81
+ }
82
+ finally {
83
+ closeSync(fd);
84
+ }
85
+ renameSync(temporary, path);
86
+ }
87
+ finally {
88
+ try {
89
+ unlinkSync(temporary);
90
+ }
91
+ catch { }
92
+ }
93
+ }
94
+ #members(pid, execution) {
95
+ const members = readShellProcessGroup(pid);
96
+ if (members.length === 0)
97
+ return [];
98
+ const known = members.some((member) => execution.members.some((previous) => previous.pid === member.pid && previous.startTime === member.startTime));
99
+ if (!known && !members.some((member) => hasShellExecutionToken(member.pid, execution.id))) {
100
+ throw new Error(`Cannot verify ownership of shell process group ${pid}`);
101
+ }
102
+ return members;
103
+ }
104
+ terminate(pid) {
105
+ const execution = this.#executions.get(pid);
106
+ if (!execution)
107
+ return;
108
+ if (execution.state === "active" && process.platform !== "win32") {
109
+ // The launching ChildProcess has not delivered its exit event yet.
110
+ execution.members = readShellProcessGroup(pid);
111
+ }
112
+ execution.state = "terminating";
113
+ if (process.platform === "win32") {
114
+ killProcessTree(pid);
115
+ }
116
+ else if (this.#members(pid, execution).length > 0) {
117
+ // Never fall back to a recycled leader PID when its group has disappeared.
118
+ try {
119
+ process.kill(-pid, "SIGKILL");
120
+ }
121
+ catch (error) {
122
+ if (error.code !== "ESRCH")
123
+ throw error;
124
+ }
125
+ }
126
+ }
127
+ releaseIfExited(pid) {
128
+ const execution = this.#executions.get(pid);
129
+ if (!execution)
130
+ return;
131
+ let alive;
132
+ if (process.platform === "win32") {
133
+ try {
134
+ process.kill(pid, 0);
135
+ alive = true;
136
+ }
137
+ catch {
138
+ alive = false;
139
+ }
140
+ }
141
+ else {
142
+ alive = this.#members(pid, execution).length > 0;
143
+ }
144
+ if (!alive) {
145
+ this.#publish(pid, execution, "session", "terminated");
146
+ this.#executions.delete(pid);
147
+ untrackDetachedChildPid(pid);
148
+ }
149
+ }
150
+ get count() {
151
+ for (const pid of this.#executions.keys())
152
+ this.releaseIfExited(pid);
153
+ return this.#executions.size;
154
+ }
155
+ async terminateAll() {
156
+ const pids = [...this.#executions.keys()];
157
+ for (const pid of pids)
158
+ this.terminate(pid);
159
+ const deadline = performance.now() + 5_000;
160
+ for (const pid of pids) {
161
+ while (this.#executions.has(pid)) {
162
+ this.releaseIfExited(pid);
163
+ if (!this.#executions.has(pid))
164
+ break;
165
+ if (performance.now() >= deadline)
166
+ throw new Error(`Shell process group ${pid} did not terminate`);
167
+ await delay(25);
168
+ }
169
+ }
170
+ }
171
+ }
@@ -0,0 +1,11 @@
1
+ export declare const SHELL_EXECUTION_ID_ENV = "DIFFEX_SHELL_EXECUTION_ID";
2
+ export interface ShellProcessIdentity {
3
+ readonly pid: number;
4
+ readonly processGroupId: number;
5
+ /** Linux boot ID and start ticks, or macOS ps lstart in UTC and the C locale. */
6
+ readonly startTime: string;
7
+ }
8
+ /** Read live group members without treating orphaned zombies as running work. */
9
+ export declare function readShellProcessGroup(processGroupId: number): ShellProcessIdentity[];
10
+ /** The launch token prevents a recycled group ID from authorizing a signal. */
11
+ export declare function hasShellExecutionToken(pid: number, executionId: string): boolean;
@@ -0,0 +1,61 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ export const SHELL_EXECUTION_ID_ENV = "DIFFEX_SHELL_EXECUTION_ID";
4
+ /** Read live group members without treating orphaned zombies as running work. */
5
+ export function readShellProcessGroup(processGroupId) {
6
+ if (process.platform === "linux") {
7
+ const bootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
8
+ const members = [];
9
+ for (const name of readdirSync("/proc")) {
10
+ if (!/^\d+$/.test(name))
11
+ continue;
12
+ try {
13
+ const stat = readFileSync(`/proc/${name}/stat`, "utf8");
14
+ const fields = stat
15
+ .slice(stat.lastIndexOf(")") + 2)
16
+ .trim()
17
+ .split(/\s+/);
18
+ if (Number(fields[2]) !== processGroupId || fields[0] === "Z")
19
+ continue;
20
+ members.push({ pid: Number(name), processGroupId, startTime: `${bootId}:${fields[19]}` });
21
+ }
22
+ catch (error) {
23
+ if (["ENOENT", "ESRCH"].includes(error.code ?? ""))
24
+ continue;
25
+ throw error;
26
+ }
27
+ }
28
+ return members;
29
+ }
30
+ const result = spawnSync("ps", ["-axo", "pid=,pgid=,stat=,lstart="], {
31
+ encoding: "utf8",
32
+ timeout: 1_000,
33
+ env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
34
+ });
35
+ if (result.status !== 0)
36
+ throw new Error("Cannot inspect shell process groups", { cause: result.error });
37
+ return result.stdout.split("\n").flatMap((line) => {
38
+ const [pid, group, state, ...started] = line.trim().split(/\s+/);
39
+ return Number(group) === processGroupId && state && !state.startsWith("Z")
40
+ ? [{ pid: Number(pid), processGroupId, startTime: started.join(" ") }]
41
+ : [];
42
+ });
43
+ }
44
+ /** The launch token prevents a recycled group ID from authorizing a signal. */
45
+ export function hasShellExecutionToken(pid, executionId) {
46
+ try {
47
+ const token = `${SHELL_EXECUTION_ID_ENV}=${executionId}`;
48
+ if (process.platform === "linux") {
49
+ return readFileSync(`/proc/${pid}/environ`, "utf8").split("\0").includes(token);
50
+ }
51
+ const result = spawnSync("ps", ["-Eww", "-p", String(pid), "-o", "command="], {
52
+ encoding: "utf8",
53
+ timeout: 1_000,
54
+ maxBuffer: 4 * 1024 * 1024,
55
+ });
56
+ return result.status === 0 && result.stdout.split(/\s+/).includes(token);
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ }
@@ -21,7 +21,7 @@ export declare function getShellEnv(): NodeJS.ProcessEnv;
21
21
  * - Characters with undefined code points
22
22
  */
23
23
  export declare function sanitizeBinaryOutput(str: string): string;
24
- export declare function trackDetachedChildPid(pid: number): void;
24
+ export declare function trackDetachedChildPid(pid: number, terminate?: () => void): void;
25
25
  export declare function untrackDetachedChildPid(pid: number): void;
26
26
  export declare function killTrackedDetachedChildren(): void;
27
27
  /**
@@ -153,16 +153,16 @@ export function sanitizeBinaryOutput(str) {
153
153
  * Detached child processes must be tracked so they can be killed on parent
154
154
  * shutdown signals (SIGHUP/SIGTERM).
155
155
  */
156
- const trackedDetachedChildPids = new Set();
157
- export function trackDetachedChildPid(pid) {
158
- trackedDetachedChildPids.add(pid);
156
+ const trackedDetachedChildPids = new Map();
157
+ export function trackDetachedChildPid(pid, terminate = () => killProcessTree(pid)) {
158
+ trackedDetachedChildPids.set(pid, terminate);
159
159
  }
160
160
  export function untrackDetachedChildPid(pid) {
161
161
  trackedDetachedChildPids.delete(pid);
162
162
  }
163
163
  export function killTrackedDetachedChildren() {
164
- for (const pid of trackedDetachedChildPids) {
165
- killProcessTree(pid);
164
+ for (const terminate of trackedDetachedChildPids.values()) {
165
+ terminate();
166
166
  }
167
167
  trackedDetachedChildPids.clear();
168
168
  }