@khorsheed/dsh-ankh-guard 0.1.1 → 0.2.0

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 (62) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.en.md +75 -29
  3. package/README.i18n.yaml +2 -2
  4. package/README.md +74 -29
  5. package/lib/cli.js +2349 -182
  6. package/lib/client.js +257 -0
  7. package/lib/exit-agent.js +5 -2
  8. package/lib/index.js +721 -38
  9. package/lib/invariant.js +1 -1
  10. package/lib/preflight-runner.js +125 -47
  11. package/lib/processes-BjZgJjQr.js +344 -0
  12. package/lib/restart-context-D6nISh28.js +1245 -0
  13. package/lib/restart-context-DUyExi9O.js +1245 -0
  14. package/lib/{state-Dhx9VG44.js → state-4f7yny39.js} +60 -13
  15. package/lib/state-CZMypGkB.js +323 -0
  16. package/lib/test-seam-DnvLWTeO.js +119 -0
  17. package/lib/test-seam-cli.js +24 -0
  18. package/lib/test-seam-dwvaKjRp.js +459 -0
  19. package/lib/test-seam.js +2 -0
  20. package/lib/types/browser-handoff.d.ts +55 -0
  21. package/lib/types/browser-handoff.js +489 -0
  22. package/lib/types/cli.d.ts +34 -4
  23. package/lib/types/cli.js +1421 -166
  24. package/lib/types/client/index.d.ts +15 -0
  25. package/lib/types/client/index.js +264 -0
  26. package/lib/types/deployment-proof.d.ts +24 -0
  27. package/lib/types/deployment-proof.js +314 -0
  28. package/lib/types/exit-agent.js +2 -0
  29. package/lib/types/git.d.ts +12 -3
  30. package/lib/types/git.js +69 -7
  31. package/lib/types/index.d.ts +66 -3
  32. package/lib/types/index.js +157 -39
  33. package/lib/types/launch-spec.d.ts +263 -0
  34. package/lib/types/launch-spec.js +823 -0
  35. package/lib/types/preflight-runner.d.ts +23 -12
  36. package/lib/types/preflight-runner.js +152 -57
  37. package/lib/types/processes.d.ts +38 -6
  38. package/lib/types/processes.js +236 -10
  39. package/lib/types/restart-context.d.ts +50 -0
  40. package/lib/types/restart-context.js +106 -0
  41. package/lib/types/restart-request.d.ts +32 -0
  42. package/lib/types/restart-request.js +128 -0
  43. package/lib/types/state-files.d.ts +30 -0
  44. package/lib/types/state-files.js +55 -0
  45. package/lib/types/state.d.ts +29 -2
  46. package/lib/types/state.js +52 -7
  47. package/lib/types/temp-artifact.d.ts +15 -0
  48. package/lib/types/temp-artifact.js +17 -0
  49. package/lib/types/test-seam-cli.d.ts +3 -0
  50. package/lib/types/test-seam-cli.js +27 -0
  51. package/lib/types/test-seam.d.ts +55 -0
  52. package/lib/types/test-seam.js +112 -0
  53. package/lib/types/transition.d.ts +118 -0
  54. package/lib/types/transition.js +717 -0
  55. package/package.json +30 -11
  56. package/scripts/dsh-watchdog.sh +1388 -80
  57. package/scripts/install-launchd.sh +43 -5
  58. package/scripts/install-systemd.sh +43 -5
  59. package/scripts/on-install.js +1 -1
  60. package/skills/dsh-self-restart-guard/SKILL.md +38 -12
  61. package/lib/processes-hCAmwma-.js +0 -127
  62. package/lib/restart-context-DmnQXNf-.js +0 -421
package/lib/invariant.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { i as resolveStateDir } from "./defaults-CduvOcGq.js";
2
- import { a as stateFilePath, n as loadState } from "./state-Dhx9VG44.js";
2
+ import { n as loadState, o as stateFilePath } from "./state-CZMypGkB.js";
3
3
  import { existsSync } from "node:fs";
4
4
  //#region lib/types/invariant.js
5
5
  /**
@@ -1,7 +1,8 @@
1
1
  import { n as isDirectInvocation } from "./defaults-CduvOcGq.js";
2
- import { existsSync, mkdtempSync, readFileSync, statSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { existsSync, mkdtempSync, statSync, writeFileSync } from "node:fs";
3
4
  import { homedir, tmpdir } from "node:os";
4
- import { join, resolve } from "node:path";
5
+ import { dirname, join, resolve } from "node:path";
5
6
  import { pathToFileURL } from "node:url";
6
7
  //#region lib/types/preflight-runner.js
7
8
  /**
@@ -11,10 +12,11 @@ import { pathToFileURL } from "node:url";
11
12
  * deploy line) was wiped by the upstream reset and cannot be re-applied
12
13
  * without re-forking apps/cli on every release — which we cannot upstream
13
14
  * (no PR access). This runner replaces it WITHOUT touching the harness: it
14
- * resolves the official published packages (`@deepseek-ai/dsh-app-boot`,
15
- * `dsh-home-paths`, `dsh-launch-environment`, `dsh-cmdline`) from the LIVE
16
- * harness checkout's node_modules, so it always dry-runs the exact engine
17
- * the next boot will use and follows host updates automatically.
15
+ * resolves the official packages (`@deepseek-ai/dsh-app-boot`,
16
+ * `dsh-home-paths`, `dsh-launch-environment`, `dsh-cmdline`) from an explicit
17
+ * execution binding: source files in the host checkout for a source launch,
18
+ * or the npm toolchain selected by a built launch's dsh package.json. It never
19
+ * infers that choice from Node's execArgv.
18
20
  *
19
21
  * What it verifies (same contract as the old patch):
20
22
  * - the profile's full patch stack composes (bundle layers from
@@ -33,8 +35,8 @@ import { pathToFileURL } from "node:url";
33
35
  * error, env load failure): NOT a verdict on the composition.
34
36
  *
35
37
  * Run directly (any profile) or via the guard CLI:
36
- * node --import <harness>/node_modules/tsx/dist/esm/index.mjs \
37
- * packages/ankh-guard/src/preflight-runner.ts --profile web [--patch FILE]...
38
+ * node packages/ankh-guard/lib/preflight-runner.js --profile web \
39
+ * --host-surface built --install-anchor <toolchain>/node_modules/@deepseek-ai/dsh/package.json
38
40
  *
39
41
  * @module @khorsheed/dsh-ankh-guard/preflight-runner
40
42
  */
@@ -49,6 +51,17 @@ const PROFILE_ROOT_FILENAME = "cordis.yml";
49
51
  const HOME_PATCH_FILENAME = "cordis.patch.yml";
50
52
  const TELEMETRY_ROW_ID = "session-telemetry-otel";
51
53
  const DSH_HARNESS_ENV = "DSH_HARNESS";
54
+ /**
55
+ * The empty root entry list every profile tree patches over — the exact
56
+ * bytes the launcher's prepareProfile rewrites on every boot (the vendored
57
+ * Loader's tree write-back can bake composed rows into this file, so both
58
+ * the launcher and this dry-run always start from the empty root).
59
+ */
60
+ const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tree is composed as patches:
61
+ # each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any
62
+ # --patch overlays. Edit cordis.patch.yml, not this file.
63
+ []
64
+ `;
52
65
  /** The guarded (or tracking) harness checkout the live instance boots from. */
53
66
  function resolveHarnessRoot(env = process.env) {
54
67
  const fromEnv = env[DSH_HARNESS_ENV];
@@ -66,18 +79,9 @@ const HARNESS_PACKAGE_DIRS = {
66
79
  "@deepseek-ai/dsh-cmdline": "packages/boot/cmdline"
67
80
  };
68
81
  /**
69
- * Whether this runtime can import TypeScript sources (the runner is launched
70
- * through tsx; a plain-node consumer of the published package cannot).
71
- */
72
- function canImportTypeScript() {
73
- return process.execArgv.some((arg) => arg.includes("tsx"));
74
- }
75
- /**
76
- * Dynamically import a harness package from the live checkout, SOURCE FIRST:
77
- * a source-launched prod instance (tsx … apps/cli/src/bin.ts) boots the
78
- * source, so the preflight engine must be the source too — a stale `lib/`
79
- * must never make the preflight greener than the real boot. The built entry
80
- * is the fallback for harnesses that ship only artifacts.
82
+ * Dynamically import one host package from the explicitly selected surface.
83
+ * A source launch uses checkout source and fails rather than falling back to
84
+ * stale lib; a built launch resolves only through its npm install anchor.
81
85
  *
82
86
  * The composition layers below (bundle layers, user layers, overlays, the
83
87
  * agent-presets roots, the telemetry switch) mirror the launcher's private
@@ -89,27 +93,38 @@ function canImportTypeScript() {
89
93
  * @param name - package name (a key of {@link HARNESS_PACKAGE_DIRS}).
90
94
  * @returns the imported module.
91
95
  */
92
- async function loadHarnessPackage(root, name) {
96
+ async function loadHarnessPackage(root, name, binding) {
97
+ if (binding.surface === "built") {
98
+ let entry;
99
+ try {
100
+ entry = createRequire(binding.installAnchor).resolve(name);
101
+ } catch (error) {
102
+ throw new Error(`built host package ${name} is not resolvable from ${binding.installAnchor}: ${String(error)}`, { cause: error });
103
+ }
104
+ return await import(__rewriteRelativeImportExtension(pathToFileURL(entry).href));
105
+ }
93
106
  const relative = HARNESS_PACKAGE_DIRS[name];
94
107
  if (relative === void 0) throw new Error(`no known harness layout entry for ${name}`);
95
108
  const base = join(root, relative);
96
109
  const source = join(base, "src", "index.ts");
97
- if (canImportTypeScript() && existsSync(source)) try {
110
+ if (!existsSync(source)) throw new Error(`harness source entry does not exist: ${source}`);
111
+ try {
98
112
  return await import(__rewriteRelativeImportExtension(pathToFileURL(source).href));
99
113
  } catch (error) {
100
114
  throw new Error(`harness source ${source} failed to import (this is what a source boot would hit): ${String(error)}`, { cause: error });
101
115
  }
102
- const entry = JSON.parse(readFileSync(join(base, "package.json"), "utf8")).main ?? "lib/index.js";
103
- return await import(__rewriteRelativeImportExtension(pathToFileURL(join(base, entry)).href));
104
116
  }
105
117
  /** Thrown for harness-side load failures — preflight infrastructure, never a composition verdict. */
106
118
  var PreflightInfraError = class extends Error {};
107
119
  /**
108
120
  * Compose one profile's full patch stack through the launcher's layering —
109
121
  * bundle layers in `dsh.profile.bundles` order, the profile user layer, the
110
- * home-level user layer, `--patch` overlays, the agent-presets roots overlay,
111
- * then the telemetry switch. Exported so the drift tripwire can compare this
112
- * assembly against the launcher's own dump without booting anything.
122
+ * home-level user layer, `--patch` overlays, the agent-presets roots overlay
123
+ * (rc host line only; the 0.1.2 line's preset package self-ships its root),
124
+ * then the telemetry switch. Two host API generations are mirrored and
125
+ * feature-detected per run — see the `hostLine` branch below. Exported so
126
+ * the drift tripwire can compare this assembly against the launcher's own
127
+ * dump without booting anything.
113
128
  * @param profile - the profile name (same resolution as `--profile`).
114
129
  * @param patchFiles - `--patch` overlay paths, in argv order.
115
130
  * @param root - harness checkout root.
@@ -118,12 +133,15 @@ var PreflightInfraError = class extends Error {};
118
133
  * specific deployment must not silently compose a different home's tree.
119
134
  * @returns the patch stack and composed rows.
120
135
  */
121
- async function composePreflightPatches(profile, patchFiles, root, home) {
136
+ async function composePreflightPatches(profile, patchFiles, root, home, binding = {
137
+ surface: "source",
138
+ installAnchor: join(root, "apps", "cli", "package.json")
139
+ }) {
122
140
  let appBoot;
123
141
  let homePaths;
124
142
  try {
125
- appBoot = await loadHarnessPackage(root, "@deepseek-ai/dsh-app-boot");
126
- homePaths = await loadHarnessPackage(root, "@deepseek-ai/dsh-home-paths");
143
+ appBoot = await loadHarnessPackage(root, "@deepseek-ai/dsh-app-boot", binding);
144
+ homePaths = await loadHarnessPackage(root, "@deepseek-ai/dsh-home-paths", binding);
127
145
  } catch (error) {
128
146
  throw new PreflightInfraError(`harness packages unavailable under ${root}: ${String(error)}`, { cause: error });
129
147
  }
@@ -133,10 +151,17 @@ async function composePreflightPatches(profile, patchFiles, root, home) {
133
151
  const loadOverlayPatches = appBoot.loadOverlayPatches;
134
152
  const loadProfile = appBoot.loadProfile;
135
153
  const resolveDshHome = homePaths.resolveDshHome;
136
- const anchor = join(root, "apps", "cli", "package.json");
154
+ const anchor = binding.installAnchor;
137
155
  const resolvedHome = resolveDshHome(home);
138
- healProfilesModuleFallback(anchor);
156
+ const hostLine = "DEFAULT_PROFILE_PATCH_RELOAD" in appBoot ? "0.1.2" : "rc";
157
+ if (hostLine === "rc") healProfilesModuleFallback(anchor, resolvedHome);
139
158
  const composed = loadProfile(NAME, profile, anchor, resolvedHome, { userLayer: true });
159
+ writeFileSync(join(composed.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG);
160
+ if (hostLine === "0.1.2") await healProfilesModuleFallback({
161
+ installAnchor: anchor,
162
+ profile: composed,
163
+ home: resolvedHome
164
+ });
140
165
  const homePatches = loadOptionalPatches(NAME, join(resolvedHome, HOME_PATCH_FILENAME)) ?? [];
141
166
  const overlays = patchFiles.flatMap((file) => loadOverlayPatches(NAME, resolve(file)));
142
167
  const bundlePatches = composed.layers.flatMap((layer) => layer.patches);
@@ -154,12 +179,13 @@ async function composePreflightPatches(profile, patchFiles, root, home) {
154
179
  overlays
155
180
  ])) if (typeof row.id === "string") rows.set(row.id, row);
156
181
  const composedOverlays = [...overlays];
157
- if (rows.has("agent-presets")) composedOverlays.push({
182
+ const shippedPresetRoot = binding.surface === "built" ? join(dirname(binding.installAnchor), "config", "agent-presets") : join(root, "apps", "cli", "config", "agent-presets");
183
+ if (rows.has("agent-presets") && existsSync(shippedPresetRoot)) composedOverlays.push({
158
184
  id: "agent-presets",
159
185
  config: {
160
186
  ...rows.get("agent-presets")?.config ?? {},
161
187
  roots: [{
162
- path: join(root, "apps/cli/config/agent-presets/"),
188
+ path: shippedPresetRoot,
163
189
  trust: "system"
164
190
  }]
165
191
  }
@@ -190,6 +216,34 @@ async function composePreflightPatches(profile, patchFiles, root, home) {
190
216
  profileDir: composed.dir
191
217
  };
192
218
  }
219
+ /**
220
+ * The launcher's readiness signal, mirrored: 0.1.2's runProfile provides an
221
+ * `appReady` service through provideCmdline and commits it once boot and host
222
+ * setup settle. The rc line's provideCmdline ignores the field, so one
223
+ * implementation serves both host lines.
224
+ */
225
+ function createAppReadyStub() {
226
+ let committed = false;
227
+ const listeners = /* @__PURE__ */ new Set();
228
+ return {
229
+ service: { onReady(listener) {
230
+ if (committed) {
231
+ listener();
232
+ return () => {};
233
+ }
234
+ listeners.add(listener);
235
+ return () => {
236
+ listeners.delete(listener);
237
+ };
238
+ } },
239
+ commit() {
240
+ if (committed) return;
241
+ committed = true;
242
+ for (const listener of [...listeners]) listener();
243
+ listeners.clear();
244
+ }
245
+ };
246
+ }
193
247
  /** Stat every registered client bundle; report one line per missing/unreadable artifact. */
194
248
  function missingClientArtifacts(ctx) {
195
249
  const registry = ctx.get?.("clientModules");
@@ -218,14 +272,17 @@ function missingClientArtifacts(ctx) {
218
272
  * @param root - harness checkout root (default: DSH_HARNESS or ~/code/deepseek-harness).
219
273
  * @returns the process exit code (see the module contract).
220
274
  */
221
- async function runPreflight(profile, patchFiles = [], root = resolveHarnessRoot()) {
275
+ async function runPreflight(profile, patchFiles = [], root = resolveHarnessRoot(), binding = {
276
+ surface: "source",
277
+ installAnchor: join(root, "apps", "cli", "package.json")
278
+ }) {
222
279
  let appBoot;
223
280
  let launchEnvironment;
224
281
  let cmdline;
225
282
  try {
226
- appBoot = await loadHarnessPackage(root, "@deepseek-ai/dsh-app-boot");
227
- launchEnvironment = await loadHarnessPackage(root, "@deepseek-ai/dsh-launch-environment");
228
- cmdline = await loadHarnessPackage(root, "@deepseek-ai/dsh-cmdline");
283
+ appBoot = await loadHarnessPackage(root, "@deepseek-ai/dsh-app-boot", binding);
284
+ launchEnvironment = await loadHarnessPackage(root, "@deepseek-ai/dsh-launch-environment", binding);
285
+ cmdline = await loadHarnessPackage(root, "@deepseek-ai/dsh-cmdline", binding);
229
286
  } catch (error) {
230
287
  process.stderr.write(`preflight could not execute (harness packages unavailable under ${root}): ${error instanceof Error ? error.message : String(error)}\n`);
231
288
  return 3;
@@ -242,7 +299,7 @@ async function runPreflight(profile, patchFiles = [], root = resolveHarnessRoot(
242
299
  return 3;
243
300
  }
244
301
  try {
245
- const composed = await composePreflightPatches(profile, patchFiles, root);
302
+ const composed = await composePreflightPatches(profile, patchFiles, root, void 0, binding);
246
303
  const patches = [...composed.patches];
247
304
  const webserver = composed.rows.get("webserver");
248
305
  if (webserver !== void 0) patches.push({
@@ -253,13 +310,16 @@ async function runPreflight(profile, patchFiles = [], root = resolveHarnessRoot(
253
310
  }
254
311
  });
255
312
  const rootConfig = join(composed.profileDir, PROFILE_ROOT_FILENAME);
313
+ const appReady = createAppReadyStub();
256
314
  const ctx = await boot(NAME, rootConfig, structuredClone(patches), (hostCtx) => {
257
315
  hostCtx.provide?.(launchEnvironmentKey, environment);
258
316
  provideCmdline(hostCtx, {
259
317
  args: [],
260
- exit: () => {}
318
+ exit: () => {},
319
+ ready: appReady.service
261
320
  });
262
321
  });
322
+ appReady.commit();
263
323
  const missing = missingClientArtifacts(ctx);
264
324
  await ctx.fiber.dispose();
265
325
  if (missing.length > 0) {
@@ -277,20 +337,24 @@ async function runPreflight(profile, patchFiles = [], root = resolveHarnessRoot(
277
337
  return 1;
278
338
  }
279
339
  }
280
- /** Minimal argv parse for `--profile NAME` and repeatable `--patch FILE`. */
340
+ /** Minimal argv parse; the host execution surface is mandatory and explicit. */
281
341
  function parsePreflightArgs(argv) {
282
342
  const patchFiles = [];
283
343
  let profile = "";
344
+ let surface = "";
345
+ let installAnchor = "";
284
346
  for (let i = 0; i < argv.length; i++) {
285
347
  const arg = argv[i];
286
348
  if (arg === "--profile") profile = argv[++i] ?? "";
287
349
  else if (arg === "--patch") {
288
350
  const file = argv[++i];
289
351
  if (file !== void 0) patchFiles.push(file);
290
- } else if (arg === "--help" || arg === "-h") return {
352
+ } else if (arg === "--host-surface") surface = argv[++i] ?? "";
353
+ else if (arg === "--install-anchor") installAnchor = argv[++i] ?? "";
354
+ else if (arg === "--help" || arg === "-h") return {
291
355
  profile: "",
292
356
  patchFiles: [],
293
- error: "usage: preflight-runner --profile <name> [--patch FILE]..."
357
+ error: "usage: preflight-runner --profile <name> --host-surface source|built --install-anchor FILE [--patch FILE]..."
294
358
  };
295
359
  }
296
360
  if (profile === "") return {
@@ -298,18 +362,32 @@ function parsePreflightArgs(argv) {
298
362
  patchFiles: [],
299
363
  error: "preflight requires --profile <name>"
300
364
  };
365
+ if (surface !== "source" && surface !== "built") return {
366
+ profile,
367
+ patchFiles,
368
+ error: "preflight requires --host-surface source|built"
369
+ };
370
+ if (installAnchor === "") return {
371
+ profile,
372
+ patchFiles,
373
+ error: "preflight requires --install-anchor FILE"
374
+ };
301
375
  return {
302
376
  profile,
303
- patchFiles
377
+ patchFiles,
378
+ binding: {
379
+ surface,
380
+ installAnchor
381
+ }
304
382
  };
305
383
  }
306
384
  if (isDirectInvocation(import.meta.url)) {
307
- const { profile, patchFiles, error } = parsePreflightArgs(process.argv.slice(2));
308
- if (error !== void 0) {
385
+ const { profile, patchFiles, binding, error } = parsePreflightArgs(process.argv.slice(2));
386
+ if (error !== void 0 || binding === void 0) {
309
387
  process.stderr.write(`${error}\n`);
310
388
  process.exit(2);
311
389
  }
312
- process.exitCode = await runPreflight(profile, patchFiles);
390
+ process.exitCode = await runPreflight(profile, patchFiles, resolveHarnessRoot(), binding);
313
391
  }
314
392
  //#endregion
315
393
  export { composePreflightPatches, parsePreflightArgs, resolveHarnessRoot, runPreflight };
@@ -0,0 +1,344 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ //#region lib/types/processes.js
5
+ /**
6
+ * Process primitives shared by the guard CLI and the exit agent: locating the
7
+ * listener on a port and killing a process tree. One owner — the watchdog's
8
+ * bash side keeps its own copy (different runtime), but every TypeScript
9
+ * caller goes through here.
10
+ */
11
+ function executable(name, candidates) {
12
+ return candidates.find((candidate) => existsSync(candidate)) ?? name;
13
+ }
14
+ const LSOF = executable("lsof", ["/usr/sbin/lsof", "/usr/bin/lsof"]);
15
+ const PS = executable("ps", ["/bin/ps", "/usr/bin/ps"]);
16
+ const PGREP = executable("pgrep", ["/usr/bin/pgrep", "/bin/pgrep"]);
17
+ const SYSCTL = executable("sysctl", ["/usr/sbin/sysctl", "/sbin/sysctl"]);
18
+ const PYTHON = executable("python3", ["/usr/bin/python3", "/opt/homebrew/bin/python3"]);
19
+ const DARWIN_START_TOKEN = [
20
+ "import ctypes,struct,sys",
21
+ "p=int(sys.argv[1])",
22
+ "b=ctypes.create_string_buffer(136)",
23
+ "n=ctypes.CDLL(\"/usr/lib/libproc.dylib\").proc_pidinfo(p,3,0,b,136)",
24
+ "n == 136 or sys.exit(1)",
25
+ "s,u=struct.unpack_from(\"QQ\",b.raw,120)",
26
+ "print(f\"darwin:{s}:{u}\",end=\"\")"
27
+ ].join(";");
28
+ /** Every process listening on a TCP port. An empty list also covers unavailable lsof. */
29
+ function findPidsOnPort(port) {
30
+ try {
31
+ const out = execFileSync(LSOF, [
32
+ `-tiTCP:${port}`,
33
+ "-sTCP:LISTEN",
34
+ "-P"
35
+ ], {
36
+ encoding: "utf8",
37
+ stdio: "pipe"
38
+ }).trim();
39
+ return [...new Set(out.split("\n").map(Number).filter((pid) => Number.isInteger(pid) && pid > 0))];
40
+ } catch {
41
+ return [];
42
+ }
43
+ }
44
+ /** TCP listen ports owned directly by one PID, using the same absolute lsof resolution. */
45
+ function listeningPortsForPid(pid) {
46
+ try {
47
+ const out = execFileSync(LSOF, [
48
+ "-nP",
49
+ "-a",
50
+ "-p",
51
+ String(pid),
52
+ "-iTCP",
53
+ "-sTCP:LISTEN"
54
+ ], {
55
+ encoding: "utf8",
56
+ stdio: "pipe"
57
+ });
58
+ return [...new Set([...out.matchAll(/:(\d+) \(LISTEN\)/g)].map((match) => Number(match[1])).filter(Number.isInteger))];
59
+ } catch {
60
+ return [];
61
+ }
62
+ }
63
+ /** The first process listening on a TCP port, or null when none is (via lsof). */
64
+ function findPidOnPort(port) {
65
+ return findPidsOnPort(port)[0]?.toString() ?? null;
66
+ }
67
+ /**
68
+ * Current identity for a live process, or null when it cannot be proved.
69
+ * Linux exposes a boot-scoped kernel start tick. Other POSIX hosts do not,
70
+ * so bind the start instant to the boot, uid, session, and settled command.
71
+ * This materially strengthens macOS ps(1)'s second-granularity lstart; all
72
+ * guard captures occur after the watchdog/child has reached its steady argv.
73
+ */
74
+ function processIdentity(pid) {
75
+ if (!Number.isInteger(pid) || pid <= 0) return null;
76
+ try {
77
+ const status = execFileSync(PS, [
78
+ "-o",
79
+ "stat=",
80
+ "-p",
81
+ String(pid)
82
+ ], {
83
+ encoding: "utf8",
84
+ stdio: "pipe"
85
+ }).trim();
86
+ if (status === "" || status.startsWith("Z")) return null;
87
+ if (process.platform === "darwin") try {
88
+ const nativeStart = execFileSync(PYTHON, [
89
+ "-c",
90
+ DARWIN_START_TOKEN,
91
+ String(pid)
92
+ ], {
93
+ encoding: "utf8",
94
+ stdio: "pipe"
95
+ }).trim();
96
+ if (/^darwin:\d+:\d+$/.test(nativeStart)) return {
97
+ pid,
98
+ startToken: nativeStart
99
+ };
100
+ } catch {}
101
+ const procStat = `/proc/${pid}/stat`;
102
+ if (existsSync(procStat)) {
103
+ const stat = readFileSync(procStat, "utf8");
104
+ const commandEnd = stat.lastIndexOf(")");
105
+ const startTicks = (commandEnd < 0 ? [] : stat.slice(commandEnd + 1).trim().split(/\s+/))[19];
106
+ if (startTicks === void 0 || startTicks === "") return null;
107
+ let bootId = "unknown-boot";
108
+ try {
109
+ bootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim() || bootId;
110
+ } catch {}
111
+ return {
112
+ pid,
113
+ startToken: `linux:${bootId}:${startTicks}`
114
+ };
115
+ }
116
+ const processEvidence = execFileSync(PS, [
117
+ "-o",
118
+ "sess=",
119
+ "-o",
120
+ "uid=",
121
+ "-o",
122
+ "lstart=",
123
+ "-o",
124
+ "command=",
125
+ "-p",
126
+ String(pid)
127
+ ], {
128
+ encoding: "utf8",
129
+ stdio: "pipe"
130
+ }).trim();
131
+ if (processEvidence === "") return null;
132
+ let bootEvidence = "unknown-boot";
133
+ try {
134
+ bootEvidence = execFileSync(SYSCTL, ["-n", "kern.boottime"], {
135
+ encoding: "utf8",
136
+ stdio: "pipe"
137
+ }).trim() || bootEvidence;
138
+ } catch {}
139
+ return {
140
+ pid,
141
+ startToken: `posix:${createHash("sha256").update(bootEvidence).update("\0").update(processEvidence).digest("hex")}`
142
+ };
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+ /** Whether the same, non-recycled process is still alive. */
148
+ function processIdentityMatches(identity) {
149
+ return processIdentity(identity.pid)?.startToken === identity.startToken;
150
+ }
151
+ function parentPid(pid) {
152
+ try {
153
+ const value = Number(execFileSync(PS, [
154
+ "-o",
155
+ "ppid=",
156
+ "-p",
157
+ String(pid)
158
+ ], {
159
+ encoding: "utf8",
160
+ stdio: "pipe"
161
+ }).trim());
162
+ return Number.isInteger(value) && value > 0 ? value : null;
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
167
+ /** The live POSIX process-group id for one PID, or null when unavailable. */
168
+ function processGroupId(pid) {
169
+ if (!Number.isInteger(pid) || pid <= 0) return null;
170
+ try {
171
+ const value = Number(execFileSync(PS, [
172
+ "-o",
173
+ "pgid=",
174
+ "-p",
175
+ String(pid)
176
+ ], {
177
+ encoding: "utf8",
178
+ stdio: "pipe"
179
+ }).trim());
180
+ return Number.isInteger(value) && value > 0 ? value : null;
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+ /**
186
+ * Prove that exactly one port listener belongs to a supervisor and return the
187
+ * direct child root through which that supervisor owns it. This is captured
188
+ * before cutover; the successor must stop this identity, never an arbitrary
189
+ * process discovered later from the shared port.
190
+ */
191
+ function findOwnedListener(port, supervisorPid) {
192
+ const listeners = findPidsOnPort(port);
193
+ if (listeners.length !== 1) return null;
194
+ const listenerPid = listeners[0];
195
+ if (listenerPid === void 0) return null;
196
+ const listener = processIdentity(listenerPid);
197
+ if (listener === null) return null;
198
+ let cursor = listenerPid;
199
+ let childRoot = null;
200
+ const seen = /* @__PURE__ */ new Set();
201
+ while (cursor > 0 && !seen.has(cursor)) {
202
+ seen.add(cursor);
203
+ const parent = parentPid(cursor);
204
+ if (parent === supervisorPid) {
205
+ childRoot = cursor;
206
+ break;
207
+ }
208
+ if (parent === null) return null;
209
+ cursor = parent;
210
+ }
211
+ if (childRoot === null) return null;
212
+ const child = processIdentity(childRoot);
213
+ return child === null ? null : {
214
+ child,
215
+ listener
216
+ };
217
+ }
218
+ /** POSIX single-quote one word for a shell command line. */
219
+ function shellQuote(word) {
220
+ return `'${word.replace(/'/g, "'\\''")}'`;
221
+ }
222
+ /**
223
+ * Discover how the process on a port was launched — its exact argv from
224
+ * `ps -o command=`, its cwd from lsof, and its DSH_* environment from
225
+ * `ps eww` — rendered as a shell command. This exists because the agent's
226
+ * sandbox blocks ps entirely, so every fresh-machine agent fell into a
227
+ * process-tree archaeology loop before its first restart; the CLI (running
228
+ * unsandboxed) answers the same question mechanically and reliably. Returns
229
+ * null when the process is gone or ps/lsof are unavailable.
230
+ * @param pid - the listener's pid.
231
+ */
232
+ function discoverLaunchCommand(pid) {
233
+ let argv;
234
+ let cwd;
235
+ try {
236
+ argv = execFileSync(PS, [
237
+ "-o",
238
+ "command=",
239
+ "-p",
240
+ pid
241
+ ], {
242
+ encoding: "utf8",
243
+ stdio: "pipe"
244
+ }).trim();
245
+ if (argv === "") return null;
246
+ } catch (error) {
247
+ throw new Error(`ps unavailable: ${String(error)}`);
248
+ }
249
+ try {
250
+ const out = execFileSync(LSOF, [
251
+ "-a",
252
+ "-p",
253
+ pid,
254
+ "-d",
255
+ "cwd",
256
+ "-Fn"
257
+ ], {
258
+ encoding: "utf8",
259
+ stdio: "pipe"
260
+ });
261
+ const match = /^n(.+)$/m.exec(out);
262
+ if (match === null) return null;
263
+ cwd = match[1] ?? "";
264
+ } catch (error) {
265
+ throw new Error(`lsof cwd unavailable: ${String(error)}`);
266
+ }
267
+ const TRANSIENT = /* @__PURE__ */ new Set([
268
+ "DSH_ANKH_RESTART_DRIVER",
269
+ "DSH_SESSION_ID",
270
+ "DSH_SESSION_JSONL",
271
+ "DSH_WEB_URL",
272
+ "DSH_SHELL"
273
+ ]);
274
+ const env = {};
275
+ try {
276
+ const out = execFileSync(PS, [
277
+ "eww",
278
+ "-o",
279
+ "command",
280
+ "-p",
281
+ pid
282
+ ], {
283
+ encoding: "utf8",
284
+ stdio: "pipe"
285
+ });
286
+ for (const token of out.split(/\s+/)) {
287
+ const eq = token.indexOf("=");
288
+ if (eq > 0 && token.slice(0, eq).startsWith("DSH_") && !TRANSIENT.has(token.slice(0, eq))) env[token.slice(0, eq)] = token.slice(eq + 1);
289
+ }
290
+ } catch {}
291
+ const envPart = Object.entries(env).map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ");
292
+ return `cd ${shellQuote(cwd)} && ${envPart !== "" ? `${envPart} ` : ""}${argv}`;
293
+ }
294
+ /**
295
+ * Kill a pid AND its descendants, deepest first (best effort). The supervised
296
+ * instance may have forked children; a plain signal on the pid alone would
297
+ * orphan them (the EADDRINUSE race the watchdog's EADDRINUSE branch exists
298
+ * for). The process-group model is NOT assumed — the instance is not
299
+ * setsid'd — so the sweep walks `pgrep -P` instead. `pgrep` missing or
300
+ * returning nothing is fine: the pid itself still gets the signal.
301
+ */
302
+ function signalFrozenTree(pid, signal) {
303
+ let safe = true;
304
+ let children = [];
305
+ try {
306
+ const out = execFileSync(PGREP, ["-P", String(pid)], {
307
+ encoding: "utf8",
308
+ stdio: "pipe"
309
+ }).trim();
310
+ children = out === "" ? [] : out.split("\n");
311
+ } catch {}
312
+ for (const raw of children) {
313
+ const child = Number(raw);
314
+ if (!Number.isInteger(child) || child <= 0) continue;
315
+ try {
316
+ process.kill(child, "SIGSTOP");
317
+ } catch {
318
+ continue;
319
+ }
320
+ if (parentPid(child) !== pid) {
321
+ safe = false;
322
+ try {
323
+ process.kill(child, "SIGCONT");
324
+ } catch {}
325
+ continue;
326
+ }
327
+ safe = signalFrozenTree(child, signal) && safe;
328
+ }
329
+ try {
330
+ process.kill(pid, signal);
331
+ if (signal !== "SIGKILL") process.kill(pid, "SIGCONT");
332
+ } catch {}
333
+ return safe;
334
+ }
335
+ function killPidTree(pid, signal) {
336
+ try {
337
+ process.kill(pid, "SIGSTOP");
338
+ } catch {
339
+ return;
340
+ }
341
+ signalFrozenTree(pid, signal);
342
+ }
343
+ //#endregion
344
+ export { listeningPortsForPid as a, processIdentityMatches as c, killPidTree as i, findOwnedListener as n, processGroupId as o, findPidOnPort as r, processIdentity as s, discoverLaunchCommand as t };